1use crate::models::{Project, Session, Tag};
2use anyhow::Result;
3use rusqlite::{params, Connection, OptionalExtension};
4use std::path::PathBuf;
5
6pub struct ProjectQueries;
7
8impl ProjectQueries {
9 pub fn create(conn: &Connection, project: &Project) -> Result<i64> {
10 let mut stmt = conn.prepare(
11 "INSERT INTO projects (name, path, git_hash, description, is_archived)
12 VALUES (?1, ?2, ?3, ?4, ?5)",
13 )?;
14
15 stmt.execute(params![
16 project.name,
17 project.path.to_string_lossy().to_string(),
18 project.git_hash,
19 project.description,
20 project.is_archived
21 ])?;
22
23 Ok(conn.last_insert_rowid())
24 }
25
26 pub fn find_by_path(conn: &Connection, path: &PathBuf) -> Result<Option<Project>> {
27 let mut stmt = conn.prepare(
28 "SELECT id, name, path, git_hash, created_at, updated_at, is_archived, description
29 FROM projects WHERE path = ?1",
30 )?;
31
32 let project = stmt
33 .query_row([path.to_string_lossy().to_string()], |row| {
34 Ok(Project {
35 id: Some(row.get(0)?),
36 name: row.get(1)?,
37 path: PathBuf::from(row.get::<_, String>(2)?),
38 git_hash: row.get(3)?,
39 created_at: row.get(4)?,
40 updated_at: row.get(5)?,
41 is_archived: row.get(6)?,
42 description: row.get(7)?,
43 })
44 })
45 .optional()?;
46
47 Ok(project)
48 }
49
50 pub fn list_all(conn: &Connection, include_archived: bool) -> Result<Vec<Project>> {
51 let sql = if include_archived {
52 "SELECT id, name, path, git_hash, created_at, updated_at, is_archived, description
53 FROM projects ORDER BY name"
54 } else {
55 "SELECT id, name, path, git_hash, created_at, updated_at, is_archived, description
56 FROM projects WHERE is_archived = 0 ORDER BY name"
57 };
58
59 let mut stmt = conn.prepare(sql)?;
60 let projects = stmt
61 .query_map([], |row| {
62 Ok(Project {
63 id: Some(row.get(0)?),
64 name: row.get(1)?,
65 path: PathBuf::from(row.get::<_, String>(2)?),
66 git_hash: row.get(3)?,
67 created_at: row.get(4)?,
68 updated_at: row.get(5)?,
69 is_archived: row.get(6)?,
70 description: row.get(7)?,
71 })
72 })?
73 .collect::<Result<Vec<_>, _>>()?;
74
75 Ok(projects)
76 }
77
78 pub fn find_by_id(conn: &Connection, project_id: i64) -> Result<Option<Project>> {
79 let mut stmt = conn.prepare(
80 "SELECT id, name, path, git_hash, created_at, updated_at, is_archived, description
81 FROM projects WHERE id = ?1",
82 )?;
83
84 let project = stmt
85 .query_row([project_id], |row| {
86 Ok(Project {
87 id: Some(row.get(0)?),
88 name: row.get(1)?,
89 path: PathBuf::from(row.get::<_, String>(2)?),
90 git_hash: row.get(3)?,
91 created_at: row.get(4)?,
92 updated_at: row.get(5)?,
93 is_archived: row.get(6)?,
94 description: row.get(7)?,
95 })
96 })
97 .optional()?;
98
99 Ok(project)
100 }
101
102 pub fn find_by_name(conn: &Connection, name: &str) -> Result<Option<Project>> {
103 let mut stmt = conn.prepare(
104 "SELECT id, name, path, git_hash, created_at, updated_at, is_archived, description
105 FROM projects WHERE name = ?1",
106 )?;
107
108 let project = stmt
109 .query_row([name], |row| {
110 Ok(Project {
111 id: Some(row.get(0)?),
112 name: row.get(1)?,
113 path: PathBuf::from(row.get::<_, String>(2)?),
114 git_hash: row.get(3)?,
115 created_at: row.get(4)?,
116 updated_at: row.get(5)?,
117 is_archived: row.get(6)?,
118 description: row.get(7)?,
119 })
120 })
121 .optional()?;
122
123 Ok(project)
124 }
125
126 pub fn archive_project(conn: &Connection, project_id: i64) -> Result<bool> {
127 let mut stmt = conn.prepare(
128 "UPDATE projects SET is_archived = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
129 )?;
130 let changes = stmt.execute([project_id])?;
131 Ok(changes > 0)
132 }
133
134 pub fn unarchive_project(conn: &Connection, project_id: i64) -> Result<bool> {
135 let mut stmt = conn.prepare(
136 "UPDATE projects SET is_archived = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
137 )?;
138 let changes = stmt.execute([project_id])?;
139 Ok(changes > 0)
140 }
141
142 pub fn update_project_path(
143 conn: &Connection,
144 project_id: i64,
145 new_path: &PathBuf,
146 ) -> Result<bool> {
147 let mut stmt = conn.prepare(
148 "UPDATE projects SET path = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
149 )?;
150 let changes = stmt.execute([
151 new_path.to_string_lossy().to_string(),
152 project_id.to_string(),
153 ])?;
154 Ok(changes > 0)
155 }
156
157 pub fn update_project_description(
158 conn: &Connection,
159 project_id: i64,
160 description: Option<String>,
161 ) -> Result<bool> {
162 let mut stmt = conn.prepare(
163 "UPDATE projects SET description = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
164 )?;
165 let changes = stmt.execute([description, Some(project_id.to_string())])?;
166 Ok(changes > 0)
167 }
168
169 pub fn delete_project(conn: &Connection, project_id: i64) -> Result<bool> {
170 let mut stmt = conn.prepare("DELETE FROM projects WHERE id = ?1")?;
171 let changes = stmt.execute([project_id])?;
172 Ok(changes > 0)
173 }
174
175 pub fn get_project_stats(
176 conn: &Connection,
177 project_id: i64,
178 ) -> Result<Option<(i64, i64, i64)>> {
179 let mut stmt = conn.prepare(
181 "SELECT
182 COUNT(*) as session_count,
183 COALESCE(SUM(CASE
184 WHEN end_time IS NOT NULL THEN
185 (julianday(end_time) - julianday(start_time)) * 86400 - paused_duration
186 ELSE 0
187 END), 0) as total_time,
188 COALESCE(AVG(CASE
189 WHEN end_time IS NOT NULL THEN
190 (julianday(end_time) - julianday(start_time)) * 86400 - paused_duration
191 ELSE 0
192 END), 0) as avg_time
193 FROM sessions WHERE project_id = ?1 AND end_time IS NOT NULL",
194 )?;
195
196 let stats = stmt
197 .query_row([project_id], |row| {
198 Ok((
199 row.get::<_, i64>(0)?,
200 row.get::<_, f64>(1)? as i64,
201 row.get::<_, f64>(2)? as i64,
202 ))
203 })
204 .optional()?;
205
206 Ok(stats)
207 }
208
209 pub fn update_name(conn: &Connection, project_id: i64, name: String) -> Result<bool> {
210 let mut stmt = conn.prepare(
211 "UPDATE projects SET name = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
212 )?;
213 let changes = stmt.execute([&name, &project_id.to_string()])?;
214 Ok(changes > 0)
215 }
216
217 pub fn update_archived(conn: &Connection, project_id: i64, archived: bool) -> Result<bool> {
218 let mut stmt = conn.prepare(
219 "UPDATE projects SET is_archived = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
220 )?;
221 let changes = stmt.execute(params![archived, project_id])?;
222 Ok(changes > 0)
223 }
224
225 pub fn list_recent_with_stats(
226 conn: &Connection,
227 limit: usize,
228 ) -> Result<Vec<(Project, i64, i64, Option<chrono::DateTime<chrono::Utc>>)>> {
229 let mut stmt = conn.prepare(
231 "SELECT
232 p.id, p.name, p.path, p.git_hash, p.created_at, p.updated_at, p.is_archived, p.description,
233 COALESCE(SUM(CASE
234 WHEN date(s.start_time) = date('now') AND s.end_time IS NOT NULL THEN
235 (julianday(s.end_time) - julianday(s.start_time)) * 86400 - s.paused_duration
236 ELSE 0
237 END), 0) as today_seconds,
238 COALESCE(SUM(CASE
239 WHEN s.end_time IS NOT NULL THEN
240 (julianday(s.end_time) - julianday(s.start_time)) * 86400 - s.paused_duration
241 ELSE 0
242 END), 0) as total_seconds,
243 MAX(s.end_time) as last_active
244 FROM projects p
245 LEFT JOIN sessions s ON p.id = s.project_id
246 WHERE p.is_archived = 0
247 GROUP BY p.id
248 ORDER BY last_active DESC NULLS LAST
249 LIMIT ?1",
250 )?;
251
252 let projects = stmt
253 .query_map([limit], |row| {
254 let project = Project {
255 id: Some(row.get(0)?),
256 name: row.get(1)?,
257 path: PathBuf::from(row.get::<_, String>(2)?),
258 git_hash: row.get(3)?,
259 created_at: row.get(4)?,
260 updated_at: row.get(5)?,
261 is_archived: row.get(6)?,
262 description: row.get(7)?,
263 };
264 let today_seconds: f64 = row.get(8)?;
265 let total_seconds: f64 = row.get(9)?;
266 let last_active: Option<chrono::DateTime<chrono::Utc>> = row.get(10)?;
267
268 Ok((
269 project,
270 today_seconds as i64,
271 total_seconds as i64,
272 last_active,
273 ))
274 })?
275 .collect::<Result<Vec<_>, _>>()?;
276
277 Ok(projects)
278 }
279}
280
281pub struct SessionQueries;
282
283impl SessionQueries {
284 pub fn create(conn: &Connection, session: &Session) -> Result<i64> {
285 let mut stmt = conn.prepare(
286 "INSERT INTO sessions (project_id, start_time, end_time, context, paused_duration, notes)
287 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
288 )?;
289
290 stmt.execute(params![
291 session.project_id,
292 session.start_time,
293 session.end_time,
294 session.context.to_string(),
295 session.paused_duration.num_seconds(),
296 session.notes
297 ])?;
298
299 Ok(conn.last_insert_rowid())
300 }
301
302 pub fn find_active_session(conn: &Connection) -> Result<Option<Session>> {
303 let mut stmt = conn.prepare(
304 "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at
305 FROM sessions WHERE end_time IS NULL LIMIT 1"
306 )?;
307
308 let session = stmt
309 .query_row([], |row| {
310 Ok(Session {
311 id: Some(row.get(0)?),
312 project_id: row.get(1)?,
313 start_time: row.get(2)?,
314 end_time: row.get(3)?,
315 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
316 rusqlite::Error::InvalidColumnType(
317 4,
318 "context".to_string(),
319 rusqlite::types::Type::Text,
320 )
321 })?,
322 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
323 notes: row.get(6)?,
324 created_at: row.get(7)?,
325 })
326 })
327 .optional()?;
328
329 Ok(session)
330 }
331
332 pub fn end_session(conn: &Connection, session_id: i64) -> Result<()> {
333 let mut stmt =
334 conn.prepare("UPDATE sessions SET end_time = CURRENT_TIMESTAMP WHERE id = ?1")?;
335
336 stmt.execute([session_id])?;
337 Ok(())
338 }
339
340 pub fn list_recent(conn: &Connection, limit: usize) -> Result<Vec<Session>> {
341 let mut stmt = conn.prepare(
342 "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at
343 FROM sessions ORDER BY start_time DESC LIMIT ?1"
344 )?;
345
346 let sessions = stmt
347 .query_map([limit], |row| {
348 Ok(Session {
349 id: Some(row.get(0)?),
350 project_id: row.get(1)?,
351 start_time: row.get(2)?,
352 end_time: row.get(3)?,
353 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
354 rusqlite::Error::InvalidColumnType(
355 4,
356 "context".to_string(),
357 rusqlite::types::Type::Text,
358 )
359 })?,
360 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
361 notes: row.get(6)?,
362 created_at: row.get(7)?,
363 })
364 })?
365 .collect::<Result<Vec<_>, _>>()?;
366
367 Ok(sessions)
368 }
369
370 pub fn find_by_id(conn: &Connection, session_id: i64) -> Result<Option<Session>> {
371 let mut stmt = conn.prepare(
372 "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at
373 FROM sessions WHERE id = ?1"
374 )?;
375
376 let session = stmt
377 .query_row([session_id], |row| {
378 Ok(Session {
379 id: Some(row.get(0)?),
380 project_id: row.get(1)?,
381 start_time: row.get(2)?,
382 end_time: row.get(3)?,
383 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
384 rusqlite::Error::InvalidColumnType(
385 4,
386 "context".to_string(),
387 rusqlite::types::Type::Text,
388 )
389 })?,
390 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
391 notes: row.get(6)?,
392 created_at: row.get(7)?,
393 })
394 })
395 .optional()?;
396
397 Ok(session)
398 }
399
400 pub fn update_session(
401 conn: &Connection,
402 session_id: i64,
403 start_time: Option<chrono::DateTime<chrono::Utc>>,
404 end_time: Option<Option<chrono::DateTime<chrono::Utc>>>,
405 project_id: Option<i64>,
406 notes: Option<Option<String>>,
407 ) -> Result<()> {
408 let mut updates = Vec::new();
409 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
410
411 if let Some(st) = start_time {
412 updates.push("start_time = ?");
413 params.push(Box::new(st));
414 }
415
416 if let Some(et) = end_time {
417 updates.push("end_time = ?");
418 params.push(Box::new(et));
419 }
420
421 if let Some(pid) = project_id {
422 updates.push("project_id = ?");
423 params.push(Box::new(pid));
424 }
425
426 if let Some(n) = notes {
427 updates.push("notes = ?");
428 params.push(Box::new(n));
429 }
430
431 if updates.is_empty() {
432 return Ok(());
433 }
434
435 params.push(Box::new(session_id));
436
437 let sql = format!("UPDATE sessions SET {} WHERE id = ?", updates.join(", "));
438 let mut stmt = conn.prepare(&sql)?;
439 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
440 stmt.execute(¶m_refs[..])?;
441
442 Ok(())
443 }
444
445 pub fn delete_session(conn: &Connection, session_id: i64) -> Result<()> {
446 let mut stmt = conn.prepare("DELETE FROM sessions WHERE id = ?1")?;
447 stmt.execute([session_id])?;
448 Ok(())
449 }
450
451 pub fn list_with_filter(
452 conn: &Connection,
453 project_id: Option<i64>,
454 start_date: Option<chrono::NaiveDate>,
455 end_date: Option<chrono::NaiveDate>,
456 limit: Option<usize>,
457 ) -> Result<Vec<Session>> {
458 let mut sql = "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at FROM sessions WHERE 1=1".to_string();
459 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
460
461 if let Some(pid) = project_id {
462 sql.push_str(" AND project_id = ?");
463 params.push(Box::new(pid));
464 }
465
466 if let Some(sd) = start_date {
467 sql.push_str(" AND date(start_time) >= ?");
468 params.push(Box::new(sd.format("%Y-%m-%d").to_string()));
469 }
470
471 if let Some(ed) = end_date {
472 sql.push_str(" AND date(start_time) <= ?");
473 params.push(Box::new(ed.format("%Y-%m-%d").to_string()));
474 }
475
476 sql.push_str(" ORDER BY start_time DESC");
477
478 if let Some(lim) = limit {
479 sql.push_str(" LIMIT ?");
480 params.push(Box::new(lim));
481 }
482
483 let mut stmt = conn.prepare(&sql)?;
484 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
485
486 let sessions = stmt
487 .query_map(¶m_refs[..], |row| {
488 Ok(Session {
489 id: Some(row.get(0)?),
490 project_id: row.get(1)?,
491 start_time: row.get(2)?,
492 end_time: row.get(3)?,
493 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
494 rusqlite::Error::InvalidColumnType(
495 4,
496 "context".to_string(),
497 rusqlite::types::Type::Text,
498 )
499 })?,
500 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
501 notes: row.get(6)?,
502 created_at: row.get(7)?,
503 })
504 })?
505 .collect::<Result<Vec<_>, _>>()?;
506
507 Ok(sessions)
508 }
509
510 pub fn bulk_update_project(
511 conn: &Connection,
512 session_ids: &[i64],
513 new_project_id: i64,
514 ) -> Result<usize> {
515 let placeholders = vec!["?"; session_ids.len()].join(",");
516 let sql = format!(
517 "UPDATE sessions SET project_id = ? WHERE id IN ({})",
518 placeholders
519 );
520
521 let mut stmt = conn.prepare(&sql)?;
522 let mut params: Vec<&dyn rusqlite::ToSql> = vec![&new_project_id];
523 for id in session_ids {
524 params.push(id);
525 }
526
527 let changes = stmt.execute(¶ms[..])?;
528 Ok(changes)
529 }
530
531 pub fn bulk_delete(conn: &Connection, session_ids: &[i64]) -> Result<usize> {
532 let placeholders = vec!["?"; session_ids.len()].join(",");
533 let sql = format!("DELETE FROM sessions WHERE id IN ({})", placeholders);
534
535 let mut stmt = conn.prepare(&sql)?;
536 let params: Vec<&dyn rusqlite::ToSql> = session_ids
537 .iter()
538 .map(|id| id as &dyn rusqlite::ToSql)
539 .collect();
540
541 let changes = stmt.execute(¶ms[..])?;
542 Ok(changes)
543 }
544
545 pub fn merge_sessions(
546 conn: &Connection,
547 session_ids: &[i64],
548 target_project_id: Option<i64>,
549 notes: Option<String>,
550 ) -> Result<i64> {
551 if session_ids.is_empty() {
552 return Err(anyhow::anyhow!("No sessions to merge"));
553 }
554
555 let placeholders = vec!["?"; session_ids.len()].join(",");
557 let sql = format!(
558 "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at
559 FROM sessions WHERE id IN ({}) ORDER BY start_time",
560 placeholders
561 );
562
563 let mut stmt = conn.prepare(&sql)?;
564 let params: Vec<&dyn rusqlite::ToSql> = session_ids
565 .iter()
566 .map(|id| id as &dyn rusqlite::ToSql)
567 .collect();
568
569 let sessions: Result<Vec<Session>, _> = stmt
570 .query_map(¶ms[..], |row| {
571 Ok(Session {
572 id: Some(row.get(0)?),
573 project_id: row.get(1)?,
574 start_time: row.get(2)?,
575 end_time: row.get(3)?,
576 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
577 rusqlite::Error::InvalidColumnType(
578 4,
579 "context".to_string(),
580 rusqlite::types::Type::Text,
581 )
582 })?,
583 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
584 notes: row.get(6)?,
585 created_at: row.get(7)?,
586 })
587 })?
588 .collect();
589
590 let sessions = sessions?;
591 if sessions.is_empty() {
592 return Err(anyhow::anyhow!("No valid sessions found to merge"));
593 }
594
595 let earliest_start =
597 sessions.iter().map(|s| s.start_time).min().ok_or_else(|| {
598 anyhow::anyhow!("No sessions found to determine earliest start time")
599 })?;
600 let latest_end = sessions.iter().filter_map(|s| s.end_time).max();
601 let total_paused = sessions
602 .iter()
603 .map(|s| s.paused_duration)
604 .fold(chrono::Duration::zero(), |acc, d| acc + d);
605 let merged_project_id = target_project_id.unwrap_or(sessions[0].project_id);
606 let merged_context = sessions[0].context; let merged_session = Session {
610 id: None,
611 project_id: merged_project_id,
612 start_time: earliest_start,
613 end_time: latest_end,
614 context: merged_context,
615 paused_duration: total_paused,
616 notes,
617 created_at: chrono::Utc::now(),
618 };
619
620 let merged_id = Self::create(conn, &merged_session)?;
622
623 for session in &sessions {
625 if let Some(session_id) = session.id {
626 SessionEditQueries::create_edit_record(
627 conn,
628 session_id,
629 session.start_time,
630 session.end_time,
631 merged_session.start_time,
632 merged_session.end_time,
633 Some(format!("Merged into session {}", merged_id)),
634 )?;
635 }
636 }
637
638 Self::bulk_delete(conn, session_ids)?;
640
641 Ok(merged_id)
642 }
643
644 pub fn split_session(
645 conn: &Connection,
646 session_id: i64,
647 split_times: &[chrono::DateTime<chrono::Utc>],
648 notes_list: Option<Vec<String>>,
649 ) -> Result<Vec<i64>> {
650 let original_session = Self::find_by_id(conn, session_id)?
652 .ok_or_else(|| anyhow::anyhow!("Session {} not found", session_id))?;
653
654 if split_times.is_empty() {
655 return Err(anyhow::anyhow!("No split times provided"));
656 }
657
658 for &split_time in split_times {
660 if split_time <= original_session.start_time {
661 return Err(anyhow::anyhow!(
662 "Split time {} is before session start",
663 split_time
664 ));
665 }
666 if let Some(end_time) = original_session.end_time {
667 if split_time >= end_time {
668 return Err(anyhow::anyhow!(
669 "Split time {} is after session end",
670 split_time
671 ));
672 }
673 }
674 }
675
676 let mut sorted_splits = split_times.to_vec();
678 sorted_splits.sort();
679
680 let mut new_session_ids = Vec::new();
681 let mut current_start = original_session.start_time;
682
683 for (i, &split_time) in sorted_splits.iter().enumerate() {
685 let segment_notes = notes_list
686 .as_ref()
687 .and_then(|list| list.get(i))
688 .cloned()
689 .or_else(|| original_session.notes.clone());
690
691 let split_session = Session {
692 id: None,
693 project_id: original_session.project_id,
694 start_time: current_start,
695 end_time: Some(split_time),
696 context: original_session.context,
697 paused_duration: chrono::Duration::zero(), notes: segment_notes,
699 created_at: chrono::Utc::now(),
700 };
701
702 let split_id = Self::create(conn, &split_session)?;
703 new_session_ids.push(split_id);
704 current_start = split_time;
705 }
706
707 let final_notes = notes_list
709 .as_ref()
710 .and_then(|list| list.get(sorted_splits.len()))
711 .cloned()
712 .or_else(|| original_session.notes.clone());
713
714 let final_session = Session {
715 id: None,
716 project_id: original_session.project_id,
717 start_time: current_start,
718 end_time: original_session.end_time,
719 context: original_session.context,
720 paused_duration: chrono::Duration::zero(),
721 notes: final_notes,
722 created_at: chrono::Utc::now(),
723 };
724
725 let final_id = Self::create(conn, &final_session)?;
726 new_session_ids.push(final_id);
727
728 SessionEditQueries::create_edit_record(
730 conn,
731 session_id,
732 original_session.start_time,
733 original_session.end_time,
734 original_session.start_time,
735 original_session.end_time,
736 Some(format!("Split into sessions: {:?}", new_session_ids)),
737 )?;
738
739 Self::delete_session(conn, session_id)?;
741
742 Ok(new_session_ids)
743 }
744
745 pub fn list_by_date_range(
746 conn: &Connection,
747 from: chrono::DateTime<chrono::Utc>,
748 to: chrono::DateTime<chrono::Utc>,
749 ) -> Result<Vec<Session>> {
750 let mut stmt = conn.prepare(
751 "SELECT id, project_id, start_time, end_time, context, paused_duration, notes, created_at
752 FROM sessions
753 WHERE start_time >= ?1 AND start_time <= ?2
754 ORDER BY start_time DESC"
755 )?;
756
757 let sessions = stmt
758 .query_map([from, to], |row| {
759 Ok(Session {
760 id: Some(row.get(0)?),
761 project_id: row.get(1)?,
762 start_time: row.get(2)?,
763 end_time: row.get(3)?,
764 context: row.get::<_, String>(4)?.parse().map_err(|_e| {
765 rusqlite::Error::InvalidColumnType(
766 4,
767 "context".to_string(),
768 rusqlite::types::Type::Text,
769 )
770 })?,
771 paused_duration: chrono::Duration::seconds(row.get::<_, i64>(5)?),
772 notes: row.get(6)?,
773 created_at: row.get(7)?,
774 })
775 })?
776 .collect::<Result<Vec<_>, _>>()?;
777
778 Ok(sessions)
779 }
780
781 pub fn get_daily_stats(conn: &Connection, date: chrono::NaiveDate) -> Result<(i64, i64, i64)> {
782 let sessions = Self::list_with_filter(conn, None, Some(date), Some(date), None)?;
783 let completed_sessions: Vec<_> = sessions
784 .into_iter()
785 .filter(|s| s.end_time.is_some())
786 .collect();
787
788 let sessions_count = completed_sessions.len() as i64;
789 let total_seconds: i64 = completed_sessions
790 .iter()
791 .map(|s| s.current_active_duration().num_seconds())
792 .sum();
793
794 let avg_seconds = if sessions_count > 0 {
795 total_seconds / sessions_count
796 } else {
797 0
798 };
799
800 Ok((sessions_count, total_seconds, avg_seconds))
801 }
802
803 pub fn get_weekly_stats(conn: &Connection, start_of_week: chrono::NaiveDate) -> Result<i64> {
804 let mut stmt = conn.prepare(
805 "SELECT
806 COALESCE(SUM(CASE
807 WHEN end_time IS NOT NULL THEN
808 (julianday(end_time) - julianday(start_time)) * 86400 - paused_duration
809 ELSE 0
810 END), 0) as total_seconds
811 FROM sessions
812 WHERE date(start_time) >= ?1",
813 )?;
814
815 let total_seconds: f64 = stmt
816 .query_row([start_of_week.format("%Y-%m-%d").to_string()], |row| {
817 row.get(0)
818 })?;
819
820 Ok(total_seconds as i64)
821 }
822}
823
824pub struct SessionEditQueries;
825
826impl SessionEditQueries {
827 pub fn create_edit_record(
828 conn: &Connection,
829 session_id: i64,
830 original_start: chrono::DateTime<chrono::Utc>,
831 original_end: Option<chrono::DateTime<chrono::Utc>>,
832 new_start: chrono::DateTime<chrono::Utc>,
833 new_end: Option<chrono::DateTime<chrono::Utc>>,
834 reason: Option<String>,
835 ) -> Result<i64> {
836 let mut stmt = conn.prepare(
837 "INSERT INTO session_edits (session_id, original_start_time, original_end_time, new_start_time, new_end_time, edit_reason)
838 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
839 )?;
840
841 stmt.execute(params![
842 session_id,
843 original_start,
844 original_end,
845 new_start,
846 new_end,
847 reason
848 ])?;
849
850 Ok(conn.last_insert_rowid())
851 }
852
853 pub fn list_session_edits(
854 conn: &Connection,
855 session_id: i64,
856 ) -> Result<Vec<crate::models::SessionEdit>> {
857 let mut stmt = conn.prepare(
858 "SELECT id, session_id, original_start_time, original_end_time, new_start_time, new_end_time, edit_reason, created_at
859 FROM session_edits WHERE session_id = ?1 ORDER BY created_at DESC"
860 )?;
861
862 let edits = stmt
863 .query_map([session_id], |row| {
864 Ok(crate::models::SessionEdit {
865 id: Some(row.get(0)?),
866 session_id: row.get(1)?,
867 original_start_time: row.get(2)?,
868 original_end_time: row.get(3)?,
869 new_start_time: row.get(4)?,
870 new_end_time: row.get(5)?,
871 edit_reason: row.get(6)?,
872 created_at: row.get(7)?,
873 })
874 })?
875 .collect::<Result<Vec<_>, _>>()?;
876
877 Ok(edits)
878 }
879}
880
881pub struct TagQueries;
882
883impl TagQueries {
884 pub fn create(conn: &Connection, tag: &Tag) -> Result<i64> {
885 let mut stmt =
886 conn.prepare("INSERT INTO tags (name, color, description) VALUES (?1, ?2, ?3)")?;
887
888 stmt.execute(params![tag.name, tag.color, tag.description])?;
889
890 Ok(conn.last_insert_rowid())
891 }
892
893 pub fn list_all(conn: &Connection) -> Result<Vec<Tag>> {
894 let mut stmt = conn
895 .prepare("SELECT id, name, color, description, created_at FROM tags ORDER BY name")?;
896
897 let tags = stmt
898 .query_map([], |row| {
899 Ok(Tag {
900 id: Some(row.get(0)?),
901 name: row.get(1)?,
902 color: row.get(2)?,
903 description: row.get(3)?,
904 created_at: row.get(4)?,
905 })
906 })?
907 .collect::<Result<Vec<_>, _>>()?;
908
909 Ok(tags)
910 }
911
912 pub fn find_by_name(conn: &Connection, name: &str) -> Result<Option<Tag>> {
913 let mut stmt = conn
914 .prepare("SELECT id, name, color, description, created_at FROM tags WHERE name = ?1")?;
915
916 let tag = stmt
917 .query_row([name], |row| {
918 Ok(Tag {
919 id: Some(row.get(0)?),
920 name: row.get(1)?,
921 color: row.get(2)?,
922 description: row.get(3)?,
923 created_at: row.get(4)?,
924 })
925 })
926 .optional()?;
927
928 Ok(tag)
929 }
930
931 pub fn delete_by_name(conn: &Connection, name: &str) -> Result<bool> {
932 let mut stmt = conn.prepare("DELETE FROM tags WHERE name = ?1")?;
933 let changes = stmt.execute([name])?;
934 Ok(changes > 0)
935 }
936
937 pub fn update_tag(
938 conn: &Connection,
939 name: &str,
940 color: Option<String>,
941 description: Option<String>,
942 ) -> Result<bool> {
943 let mut updates = Vec::new();
944 let mut params: Vec<&dyn rusqlite::ToSql> = Vec::new();
945
946 if let Some(c) = &color {
947 updates.push("color = ?");
948 params.push(c);
949 }
950
951 if let Some(d) = &description {
952 updates.push("description = ?");
953 params.push(d);
954 }
955
956 if updates.is_empty() {
957 return Ok(false);
958 }
959
960 params.push(&name);
961
962 let sql = format!("UPDATE tags SET {} WHERE name = ?", updates.join(", "));
963 let mut stmt = conn.prepare(&sql)?;
964 let changes = stmt.execute(¶ms[..])?;
965
966 Ok(changes > 0)
967 }
968}