1mod export;
2mod import;
3mod schema;
4
5use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection};
10
11use crate::model::{
12 AppData, EmptyQueueBehavior, EstimateCompleteBehavior, FocusSessionRecord, Priority,
13 StoredSession, Subtask, Task, TaskRecurrence, TaskStatus, TimerMode,
14};
15use crate::theme;
16
17pub struct Database {
18 conn: Connection,
19}
20
21impl Database {
22 pub fn open() -> Result<Self> {
23 let path = db_path()?;
24 let existed = path.exists();
25 let conn = Connection::open(&path).context("opening SQLite database")?;
26 conn.pragma_update(None, "journal_mode", "WAL")?;
27 conn.pragma_update(None, "foreign_keys", "ON")?;
28 schema::migrate(&conn)?;
29
30 let db = Self { conn };
31 if !existed {
32 let json = legacy_json_path()?;
33 if json.exists() {
34 import::import_json(&db, &json)?;
35 let backup = json.with_extension("json.migrated");
36 let _ = std::fs::rename(&json, &backup);
37 }
38 }
39 Ok(db)
40 }
41
42 pub fn load_app_data(&self) -> Result<AppData> {
43 let mut data = AppData::default();
44 load_settings(&self.conn, &mut data)?;
45 data.tasks = load_tasks(&self.conn)?;
46 data.session_history = Vec::new();
47 Ok(data)
48 }
49
50 pub fn save_app_data(&self, data: &AppData) -> Result<()> {
51 let tx = self.conn.unchecked_transaction()?;
52 save_settings(&tx, data)?;
53 sync_tasks(&tx, &data.tasks)?;
54 tx.commit()?;
55 Ok(())
56 }
57
58 pub fn insert_focus_session(&self, record: &FocusSessionRecord) -> Result<i64> {
59 self.conn.execute(
60 "INSERT INTO focus_sessions (date, minutes, task_id, mode, completed_at, note, pause_count, pause_seconds)
61 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
62 params![
63 record.date,
64 record.minutes,
65 record.task_id.map(|id| id as i64),
66 encode_timer_mode(record.mode),
67 record.completed_at.to_rfc3339(),
68 record.note,
69 record.pause_count,
70 record.pause_seconds,
71 ],
72 )?;
73 let id = self.conn.last_insert_rowid();
74 for tag in &record.tags {
75 self.conn.execute(
76 "INSERT INTO session_tags (session_id, tag) VALUES (?1, ?2)",
77 params![id, tag],
78 )?;
79 }
80 Ok(id)
81 }
82
83 pub fn get_session(&self, id: i64) -> Result<StoredSession> {
84 let record = self.conn.query_row(
85 "SELECT date, minutes, task_id, mode, completed_at, note, pause_count, pause_seconds
86 FROM focus_sessions WHERE id = ?1",
87 params![id],
88 |row| {
89 let mode_str: String = row.get(3)?;
90 Ok(FocusSessionRecord {
91 date: row.get(0)?,
92 minutes: row.get(1)?,
93 task_id: read_opt_u64(row, 2)?,
94 mode: decode_timer_mode(&mode_str),
95 completed_at: parse_datetime(&row.get::<_, String>(4)?),
96 note: row.get(5)?,
97 pause_count: row.get(6)?,
98 pause_seconds: row.get(7)?,
99 tags: Vec::new(),
100 })
101 },
102 )?;
103 Ok(StoredSession {
104 id,
105 record: FocusSessionRecord {
106 tags: load_session_tags(&self.conn, id)?,
107 ..record
108 },
109 })
110 }
111
112 pub fn delete_focus_session(&self, id: i64) -> Result<()> {
113 self.conn
114 .execute("DELETE FROM focus_sessions WHERE id = ?1", params![id])?;
115 Ok(())
116 }
117
118 pub fn update_session_minutes(&self, id: i64, minutes: u32) -> Result<()> {
119 self.conn.execute(
120 "UPDATE focus_sessions SET minutes = ?1 WHERE id = ?2",
121 params![minutes, id],
122 )?;
123 Ok(())
124 }
125
126 pub fn recent_sessions(&self, limit: usize) -> Result<Vec<StoredSession>> {
127 self.recent_sessions_paged(0, limit)
128 }
129
130 pub fn recent_sessions_paged(&self, offset: usize, limit: usize) -> Result<Vec<StoredSession>> {
131 let mut stmt = self.conn.prepare(
132 "SELECT id, date, minutes, task_id, mode, completed_at, note, pause_count, pause_seconds
133 FROM focus_sessions
134 ORDER BY completed_at DESC
135 LIMIT ?1 OFFSET ?2",
136 )?;
137 let rows = stmt.query_map(params![limit as i64, offset as i64], |row| {
138 let id: i64 = row.get(0)?;
139 let mode_str: String = row.get(4)?;
140 Ok((
141 id,
142 FocusSessionRecord {
143 date: row.get(1)?,
144 minutes: row.get(2)?,
145 task_id: read_opt_u64(row, 3)?,
146 mode: decode_timer_mode(&mode_str),
147 completed_at: parse_datetime(&row.get::<_, String>(5)?),
148 note: row.get(6)?,
149 pause_count: row.get(7)?,
150 pause_seconds: row.get(8)?,
151 tags: Vec::new(),
152 },
153 ))
154 })?;
155 let mut out = Vec::new();
156 for row in rows {
157 let (id, mut record) = row?;
158 record.tags = load_session_tags(&self.conn, id)?;
159 out.push(StoredSession { id, record });
160 }
161 Ok(out)
162 }
163
164 pub fn session_count(&self) -> Result<usize> {
165 let count: i64 = self
166 .conn
167 .query_row("SELECT COUNT(*) FROM focus_sessions", [], |row| row.get(0))?;
168 Ok(count as usize)
169 }
170
171 pub fn sessions_on_date(&self, date: &str) -> Result<Vec<StoredSession>> {
172 let mut stmt = self.conn.prepare(
173 "SELECT id, date, minutes, task_id, mode, completed_at, note, pause_count, pause_seconds
174 FROM focus_sessions
175 WHERE date = ?1
176 ORDER BY completed_at ASC",
177 )?;
178 let rows = stmt.query_map(params![date], |row| {
179 let id: i64 = row.get(0)?;
180 let mode_str: String = row.get(4)?;
181 Ok((
182 id,
183 FocusSessionRecord {
184 date: row.get(1)?,
185 minutes: row.get(2)?,
186 task_id: read_opt_u64(row, 3)?,
187 mode: decode_timer_mode(&mode_str),
188 completed_at: parse_datetime(&row.get::<_, String>(5)?),
189 note: row.get(6)?,
190 pause_count: row.get(7)?,
191 pause_seconds: row.get(8)?,
192 tags: Vec::new(),
193 },
194 ))
195 })?;
196 let mut out = Vec::new();
197 for row in rows {
198 let (id, mut record) = row?;
199 record.tags = load_session_tags(&self.conn, id)?;
200 out.push(StoredSession { id, record });
201 }
202 Ok(out)
203 }
204
205 pub fn session_counts_by_mode(&self) -> Result<(u32, u32, u32)> {
206 let focus: u32 = self.conn.query_row(
207 "SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
208 params![encode_timer_mode(TimerMode::Focus)],
209 |row| row.get(0),
210 )?;
211 let custom: u32 = self.conn.query_row(
212 "SELECT COUNT(*) FROM focus_sessions WHERE mode = ?1",
213 params![encode_timer_mode(TimerMode::Custom)],
214 |row| row.get(0),
215 )?;
216 let breaks: u32 = self.conn.query_row(
217 "SELECT COUNT(*) FROM focus_sessions WHERE mode IN (?1, ?2)",
218 params![
219 encode_timer_mode(TimerMode::ShortBreak),
220 encode_timer_mode(TimerMode::LongBreak),
221 ],
222 |row| row.get(0),
223 )?;
224 Ok((focus, custom, breaks))
225 }
226
227 pub fn load_timer_state(&self) -> (u32, TimerMode) {
228 let count: u32 = self
229 .conn
230 .query_row(
231 "SELECT value FROM settings WHERE key = 'timer_completed_focus_sessions'",
232 [],
233 |row| row.get::<_, String>(0),
234 )
235 .ok()
236 .and_then(|s| s.parse().ok())
237 .unwrap_or(0);
238 let mode = self
239 .conn
240 .query_row(
241 "SELECT value FROM settings WHERE key = 'timer_mode'",
242 [],
243 |row| row.get::<_, String>(0),
244 )
245 .ok()
246 .map(|s| decode_timer_mode(&s))
247 .unwrap_or(TimerMode::Focus);
248 (count, mode)
249 }
250
251 pub fn persist_timer_state(&self, completed: u32, mode: TimerMode) -> Result<()> {
252 self.set_setting("timer_completed_focus_sessions", completed.to_string())?;
253 self.set_setting("timer_mode", encode_timer_mode(mode))?;
254 Ok(())
255 }
256
257 pub fn set_setting(&self, key: &str, value: impl AsRef<str>) -> Result<()> {
258 self.conn.execute(
259 "INSERT INTO settings (key, value) VALUES (?1, ?2)
260 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
261 params![key, value.as_ref()],
262 )?;
263 Ok(())
264 }
265
266 pub fn upsert_task(&self, task: &Task) -> Result<()> {
267 let tx = self.conn.unchecked_transaction()?;
268 upsert_task_row(&tx, task)?;
269 tx.commit()?;
270 Ok(())
271 }
272
273 pub fn delete_task(&self, id: u64) -> Result<()> {
274 self.conn
275 .execute("DELETE FROM tasks WHERE id = ?1", params![id as i64])?;
276 Ok(())
277 }
278
279 pub fn sync_sort_orders(&self, tasks: &[Task]) -> Result<()> {
280 let tx = self.conn.unchecked_transaction()?;
281 for task in tasks {
282 tx.execute(
283 "UPDATE tasks SET sort_order = ?1 WHERE id = ?2",
284 params![task.sort_order, task.id as i64],
285 )?;
286 }
287 tx.commit()?;
288 Ok(())
289 }
290
291 pub fn persist_session_stats(&self, data: &AppData) -> Result<()> {
292 self.set_setting("total_focus_minutes", data.total_focus_minutes.to_string())?;
293 self.set_setting("total_sessions", data.total_sessions.to_string())?;
294 self.set_setting("streak_days", data.streak_days.to_string())?;
295 self.set_setting(
296 "last_session_date",
297 data.last_session_date.clone().unwrap_or_default(),
298 )?;
299 self.set_setting("today_focus_minutes", data.today_focus_minutes.to_string())?;
300 self.set_setting("today_date", data.today_date.clone().unwrap_or_default())?;
301 self.set_setting("goal_streak_days", data.goal_streak_days.to_string())?;
302 self.set_setting(
303 "last_goal_date",
304 data.last_goal_date.clone().unwrap_or_default(),
305 )?;
306 Ok(())
307 }
308
309 pub fn persist_timer_settings(&self, data: &AppData) -> Result<()> {
310 self.set_setting("focus_minutes", data.focus_minutes.to_string())?;
311 self.set_setting("short_break_minutes", data.short_break_minutes.to_string())?;
312 self.set_setting("long_break_minutes", data.long_break_minutes.to_string())?;
313 self.set_setting("long_break_every", data.long_break_every.to_string())?;
314 Ok(())
315 }
316
317 pub fn persist_active_task(&self, id: Option<u64>) -> Result<()> {
318 let value = id.map(|i| i.to_string()).unwrap_or_default();
319 self.set_setting("active_task_id", value)
320 }
321
322 pub fn export_json(&self) -> Result<PathBuf> {
323 export::export_json(&self.conn)
324 }
325
326 pub fn minutes_by_date(&self, days: usize) -> Result<Vec<(String, u32)>> {
327 let today = chrono::Local::now().date_naive();
328 let mut out = Vec::with_capacity(days);
329 for offset in (0..days).rev() {
330 let date = today - chrono::Duration::days(offset as i64);
331 let key = date.format("%Y-%m-%d").to_string();
332 let mins = self.focus_minutes_on_date(&key)?;
333 let label = date.format("%a").to_string();
334 out.push((label, mins));
335 }
336 Ok(out)
337 }
338
339 pub fn focus_minutes_series(&self, days: usize) -> Result<Vec<(String, u32)>> {
341 let today = chrono::Local::now().date_naive();
342 let mut out = Vec::with_capacity(days);
343 for offset in (0..days).rev() {
344 let date = today - chrono::Duration::days(offset as i64);
345 let key = date.format("%Y-%m-%d").to_string();
346 let mins = self.focus_minutes_on_date(&key)?;
347 out.push((key, mins));
348 }
349 Ok(out)
350 }
351
352 pub fn focus_minutes_grouped(&self) -> Result<Vec<(String, u32)>> {
354 let mut stmt = self.conn.prepare(
355 "SELECT date, COALESCE(SUM(minutes), 0) AS mins
356 FROM focus_sessions
357 WHERE mode IN (?1, ?2)
358 GROUP BY date
359 ORDER BY date ASC",
360 )?;
361 let rows = stmt.query_map(
362 params![
363 encode_timer_mode(TimerMode::Focus),
364 encode_timer_mode(TimerMode::Custom),
365 ],
366 |row| Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)),
367 )?;
368 rows.collect::<Result<Vec<_>, _>>()
369 .context("loading focus minutes")
370 }
371
372 fn focus_minutes_on_date(&self, key: &str) -> Result<u32> {
373 self.conn
374 .query_row(
375 "SELECT COALESCE(SUM(minutes), 0) FROM focus_sessions
376 WHERE date = ?1 AND mode IN (?2, ?3)",
377 params![
378 key,
379 encode_timer_mode(TimerMode::Focus),
380 encode_timer_mode(TimerMode::Custom),
381 ],
382 |row| row.get(0),
383 )
384 .map_err(Into::into)
385 }
386}
387
388pub fn db_path() -> Result<PathBuf> {
389 let dir = data_dir()?;
390 Ok(dir.join("void.db"))
391}
392
393pub fn legacy_json_path() -> Result<PathBuf> {
394 Ok(data_dir()?.join("data.json"))
395}
396
397fn data_dir() -> Result<PathBuf> {
398 let dir = dirs::data_local_dir()
399 .or_else(dirs::config_dir)
400 .context("could not resolve local data directory")?;
401 let focus_dir = dir.join("void");
402 std::fs::create_dir_all(&focus_dir).context("creating data directory")?;
403 Ok(focus_dir)
404}
405
406pub(crate) fn load_settings(conn: &Connection, data: &mut AppData) -> Result<()> {
409 let mut stmt = conn.prepare("SELECT key, value FROM settings")?;
410 let rows = stmt.query_map([], |row| {
411 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
412 })?;
413 for row in rows {
414 let (key, value) = row?;
415 apply_setting(data, &key, &value);
416 }
417 Ok(())
418}
419
420fn save_settings(conn: &Connection, data: &AppData) -> Result<()> {
421 let pairs: Vec<(&str, String)> = vec![
422 ("next_id", data.next_id.to_string()),
423 ("total_focus_minutes", data.total_focus_minutes.to_string()),
424 ("total_sessions", data.total_sessions.to_string()),
425 ("streak_days", data.streak_days.to_string()),
426 (
427 "last_session_date",
428 data.last_session_date.clone().unwrap_or_default(),
429 ),
430 ("daily_goal_minutes", data.daily_goal_minutes.to_string()),
431 ("sound_enabled", bool_str(data.sound_enabled)),
432 ("auto_start_breaks", bool_str(data.auto_start_breaks)),
433 ("auto_start_focus", bool_str(data.auto_start_focus)),
434 ("today_focus_minutes", data.today_focus_minutes.to_string()),
435 ("today_date", data.today_date.clone().unwrap_or_default()),
436 ("focus_minutes", data.focus_minutes.to_string()),
437 ("short_break_minutes", data.short_break_minutes.to_string()),
438 ("long_break_minutes", data.long_break_minutes.to_string()),
439 ("long_break_every", data.long_break_every.to_string()),
440 ("auto_pick_task", bool_str(data.auto_pick_task)),
441 ("auto_advance_task", bool_str(data.auto_advance_task)),
442 ("theme", data.theme.clone()),
443 (
444 "active_task_id",
445 data.active_task_id
446 .map(|id| id.to_string())
447 .unwrap_or_default(),
448 ),
449 ("notify_on_finish", bool_str(data.notify_on_finish)),
450 ("goal_streak_days", data.goal_streak_days.to_string()),
451 (
452 "last_goal_date",
453 data.last_goal_date.clone().unwrap_or_default(),
454 ),
455 (
456 "empty_queue_behavior",
457 encode_empty_queue(data.empty_queue_behavior).to_string(),
458 ),
459 ("log_breaks", bool_str(data.log_breaks)),
460 (
461 "estimate_complete",
462 encode_estimate_complete(data.estimate_complete).to_string(),
463 ),
464 ("show_terminal_title", bool_str(data.show_terminal_title)),
465 ("warn_one_minute", bool_str(data.warn_one_minute)),
466 (
467 "auto_pause_idle_minutes",
468 data.auto_pause_idle_minutes.to_string(),
469 ),
470 ("archive_after_days", data.archive_after_days.to_string()),
471 ("weekly_streak_weeks", data.weekly_streak_weeks.to_string()),
472 (
473 "monthly_streak_months",
474 data.monthly_streak_months.to_string(),
475 ),
476 (
477 "last_weekly_streak_key",
478 data.last_weekly_streak_key.clone().unwrap_or_default(),
479 ),
480 (
481 "last_monthly_streak_key",
482 data.last_monthly_streak_key.clone().unwrap_or_default(),
483 ),
484 (
485 "timer_presets",
486 serde_json::to_string(&data.timer_presets).unwrap_or_default(),
487 ),
488 (
489 "active_preset",
490 data.active_preset.clone().unwrap_or_default(),
491 ),
492 ];
493
494 for (key, value) in pairs {
495 conn.execute(
496 "INSERT INTO settings (key, value) VALUES (?1, ?2)
497 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
498 params![key, value],
499 )?;
500 }
501 Ok(())
502}
503
504fn apply_setting(data: &mut AppData, key: &str, value: &str) {
505 match key {
506 "next_id" => data.next_id = parse_u64(value, data.next_id),
507 "total_focus_minutes" => {
508 data.total_focus_minutes = parse_u32(value, data.total_focus_minutes)
509 }
510 "total_sessions" => data.total_sessions = parse_u32(value, data.total_sessions),
511 "streak_days" => data.streak_days = parse_u32(value, data.streak_days),
512 "last_session_date" => data.last_session_date = opt_string(value),
513 "daily_goal_minutes" => data.daily_goal_minutes = parse_u32(value, data.daily_goal_minutes),
514 "sound_enabled" => data.sound_enabled = parse_bool(value, data.sound_enabled),
515 "auto_start_breaks" => data.auto_start_breaks = parse_bool(value, data.auto_start_breaks),
516 "auto_start_focus" => data.auto_start_focus = parse_bool(value, data.auto_start_focus),
517 "today_focus_minutes" => {
518 data.today_focus_minutes = parse_u32(value, data.today_focus_minutes)
519 }
520 "today_date" => data.today_date = opt_string(value),
521 "focus_minutes" => data.focus_minutes = parse_u32(value, data.focus_minutes),
522 "short_break_minutes" => {
523 data.short_break_minutes = parse_u32(value, data.short_break_minutes)
524 }
525 "long_break_minutes" => data.long_break_minutes = parse_u32(value, data.long_break_minutes),
526 "long_break_every" => data.long_break_every = parse_u32(value, data.long_break_every),
527 "auto_pick_task" => data.auto_pick_task = parse_bool(value, data.auto_pick_task),
528 "auto_advance_task" => data.auto_advance_task = parse_bool(value, data.auto_advance_task),
529 "theme" if !value.is_empty() => {
530 data.theme = theme::normalize_theme_id(value);
531 }
532 "active_task_id" => data.active_task_id = value.parse().ok(),
533 "notify_on_finish" => data.notify_on_finish = parse_bool(value, data.notify_on_finish),
534 "goal_streak_days" => data.goal_streak_days = parse_u32(value, data.goal_streak_days),
535 "last_goal_date" => data.last_goal_date = opt_string(value),
536 "empty_queue_behavior" => {
537 data.empty_queue_behavior =
538 decode_empty_queue(value).unwrap_or(data.empty_queue_behavior)
539 }
540 "log_breaks" => data.log_breaks = parse_bool(value, data.log_breaks),
541 "estimate_complete" => {
542 data.estimate_complete =
543 decode_estimate_complete(value).unwrap_or(data.estimate_complete)
544 }
545 "show_terminal_title" => {
546 data.show_terminal_title = parse_bool(value, data.show_terminal_title)
547 }
548 "warn_one_minute" => data.warn_one_minute = parse_bool(value, data.warn_one_minute),
549 "auto_pause_idle_minutes" => {
550 data.auto_pause_idle_minutes = parse_u32(value, data.auto_pause_idle_minutes)
551 }
552 "archive_after_days" => data.archive_after_days = parse_u32(value, data.archive_after_days),
553 "weekly_streak_weeks" => {
554 data.weekly_streak_weeks = parse_u32(value, data.weekly_streak_weeks)
555 }
556 "monthly_streak_months" => {
557 data.monthly_streak_months = parse_u32(value, data.monthly_streak_months)
558 }
559 "last_weekly_streak_key" => data.last_weekly_streak_key = opt_string(value),
560 "last_monthly_streak_key" => data.last_monthly_streak_key = opt_string(value),
561 "timer_presets" if !value.is_empty() => {
562 if let Ok(presets) = serde_json::from_str(value) {
563 data.timer_presets = presets;
564 }
565 }
566 "active_preset" => data.active_preset = opt_string(value),
567 _ => {}
568 }
569}
570
571pub(crate) fn load_tasks(conn: &Connection) -> Result<Vec<Task>> {
574 let mut stmt = conn.prepare(
575 "SELECT id, title, notes, priority, status, estimated_minutes, actual_minutes,
576 sessions, created_at, completed_at, due_date, today, sort_order,
577 archived, recurrence
578 FROM tasks
579 ORDER BY sort_order ASC, id ASC",
580 )?;
581 let rows = stmt.query_map([], |row| {
582 Ok(Task {
583 id: read_u64(row, 0)?,
584 title: row.get(1)?,
585 notes: row.get(2)?,
586 priority: decode_priority(&row.get::<_, String>(3)?),
587 status: decode_task_status(&row.get::<_, String>(4)?),
588 estimated_minutes: row.get(5)?,
589 actual_minutes: row.get(6)?,
590 sessions: row.get(7)?,
591 created_at: parse_datetime(&row.get::<_, String>(8)?),
592 completed_at: row.get::<_, Option<String>>(9)?.map(|s| parse_datetime(&s)),
593 due_date: row.get::<_, Option<String>>(10)?,
594 today: row.get::<_, i32>(11)? != 0,
595 sort_order: row.get(12)?,
596 archived: row.get::<_, i32>(13)? != 0,
597 recurrence: decode_recurrence(&row.get::<_, String>(14)?),
598 subtasks: Vec::new(),
599 blocked_by: Vec::new(),
600 tags: Vec::new(),
601 })
602 })?;
603
604 let mut tasks = Vec::new();
605 for row in rows {
606 let mut task = row?;
607 task.tags = load_task_tags(conn, task.id)?;
608 task.subtasks = load_subtasks(conn, task.id)?;
609 task.blocked_by = load_blocked_by(conn, task.id)?;
610 tasks.push(task);
611 }
612 Ok(tasks)
613}
614
615fn load_task_tags(conn: &Connection, task_id: u64) -> Result<Vec<String>> {
616 let mut stmt = conn.prepare("SELECT tag FROM task_tags WHERE task_id = ?1 ORDER BY tag ASC")?;
617 let tags = stmt
618 .query_map(params![task_id as i64], |row| row.get(0))?
619 .collect::<Result<Vec<String>, _>>()?;
620 Ok(tags)
621}
622
623fn sync_tasks(conn: &Connection, tasks: &[Task]) -> Result<()> {
624 conn.execute("DELETE FROM task_tags", [])?;
625 conn.execute("DELETE FROM task_blocked_by", [])?;
626 conn.execute("DELETE FROM subtasks", [])?;
627 conn.execute("DELETE FROM tasks", [])?;
628 for task in tasks {
629 upsert_task_row(conn, task)?;
630 }
631 Ok(())
632}
633
634fn upsert_task_row(conn: &Connection, task: &Task) -> Result<()> {
635 conn.execute(
636 "INSERT INTO tasks (
637 id, title, notes, priority, status, estimated_minutes, actual_minutes,
638 sessions, created_at, completed_at, due_date, today, sort_order,
639 archived, recurrence
640 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)
641 ON CONFLICT(id) DO UPDATE SET
642 title = excluded.title,
643 notes = excluded.notes,
644 priority = excluded.priority,
645 status = excluded.status,
646 estimated_minutes = excluded.estimated_minutes,
647 actual_minutes = excluded.actual_minutes,
648 sessions = excluded.sessions,
649 created_at = excluded.created_at,
650 completed_at = excluded.completed_at,
651 due_date = excluded.due_date,
652 today = excluded.today,
653 sort_order = excluded.sort_order,
654 archived = excluded.archived,
655 recurrence = excluded.recurrence",
656 params![
657 task.id as i64,
658 task.title,
659 task.notes,
660 encode_priority(task.priority),
661 encode_task_status(task.status),
662 task.estimated_minutes,
663 task.actual_minutes,
664 task.sessions,
665 task.created_at.to_rfc3339(),
666 task.completed_at.map(|dt| dt.to_rfc3339()),
667 task.due_date,
668 if task.today { 1 } else { 0 },
669 task.sort_order,
670 if task.archived { 1 } else { 0 },
671 encode_recurrence(task.recurrence),
672 ],
673 )?;
674 conn.execute(
675 "DELETE FROM task_tags WHERE task_id = ?1",
676 params![task.id as i64],
677 )?;
678 for tag in &task.tags {
679 conn.execute(
680 "INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)",
681 params![task.id as i64, tag],
682 )?;
683 }
684 conn.execute(
685 "DELETE FROM subtasks WHERE task_id = ?1",
686 params![task.id as i64],
687 )?;
688 for (i, sub) in task.subtasks.iter().enumerate() {
689 conn.execute(
690 "INSERT INTO subtasks (id, task_id, title, done, sort_order) VALUES (?1, ?2, ?3, ?4, ?5)",
691 params![
692 sub.id as i64,
693 task.id as i64,
694 sub.title,
695 if sub.done { 1 } else { 0 },
696 i as i64,
697 ],
698 )?;
699 }
700 conn.execute(
701 "DELETE FROM task_blocked_by WHERE task_id = ?1",
702 params![task.id as i64],
703 )?;
704 for blocker_id in &task.blocked_by {
705 conn.execute(
706 "INSERT INTO task_blocked_by (task_id, blocker_id) VALUES (?1, ?2)",
707 params![task.id as i64, *blocker_id as i64],
708 )?;
709 }
710 Ok(())
711}
712
713fn load_subtasks(conn: &Connection, task_id: u64) -> Result<Vec<Subtask>> {
714 let mut stmt = conn.prepare(
715 "SELECT id, title, done FROM subtasks WHERE task_id = ?1 ORDER BY sort_order ASC",
716 )?;
717 let rows = stmt.query_map(params![task_id as i64], |row| {
718 Ok(Subtask {
719 id: read_u64(row, 0)?,
720 title: row.get(1)?,
721 done: row.get::<_, i32>(2)? != 0,
722 })
723 })?;
724 rows.collect::<Result<Vec<_>, _>>()
725 .context("loading subtasks")
726}
727
728fn load_blocked_by(conn: &Connection, task_id: u64) -> Result<Vec<u64>> {
729 let mut stmt = conn
730 .prepare("SELECT blocker_id FROM task_blocked_by WHERE task_id = ?1 ORDER BY blocker_id")?;
731 let rows = stmt.query_map(params![task_id as i64], |row| read_u64(row, 0))?;
732 rows.collect::<Result<Vec<_>, _>>()
733 .context("loading task blockers")
734}
735
736fn load_session_tags(conn: &Connection, session_id: i64) -> Result<Vec<String>> {
737 let mut stmt =
738 conn.prepare("SELECT tag FROM session_tags WHERE session_id = ?1 ORDER BY tag ASC")?;
739 let tags = stmt
740 .query_map(params![session_id], |row| row.get(0))?
741 .collect::<Result<Vec<String>, _>>()?;
742 Ok(tags)
743}
744
745fn encode_recurrence(r: TaskRecurrence) -> &'static str {
746 match r {
747 TaskRecurrence::None => "none",
748 TaskRecurrence::Daily => "daily",
749 TaskRecurrence::Weekly => "weekly",
750 TaskRecurrence::Weekdays => "weekdays",
751 }
752}
753
754fn decode_recurrence(s: &str) -> TaskRecurrence {
755 match s {
756 "daily" => TaskRecurrence::Daily,
757 "weekly" => TaskRecurrence::Weekly,
758 "weekdays" => TaskRecurrence::Weekdays,
759 _ => TaskRecurrence::None,
760 }
761}
762
763fn encode_priority(p: Priority) -> &'static str {
766 match p {
767 Priority::Low => "low",
768 Priority::Medium => "medium",
769 Priority::High => "high",
770 }
771}
772
773fn decode_priority(s: &str) -> Priority {
774 match s {
775 "high" => Priority::High,
776 "low" => Priority::Low,
777 _ => Priority::Medium,
778 }
779}
780
781fn encode_task_status(s: TaskStatus) -> &'static str {
782 match s {
783 TaskStatus::Pending => "pending",
784 TaskStatus::InProgress => "inprogress",
785 TaskStatus::Done => "done",
786 }
787}
788
789fn decode_task_status(s: &str) -> TaskStatus {
790 match s {
791 "done" => TaskStatus::Done,
792 "inprogress" | "in_progress" => TaskStatus::InProgress,
793 _ => TaskStatus::Pending,
794 }
795}
796
797fn encode_timer_mode(m: TimerMode) -> &'static str {
798 match m {
799 TimerMode::Focus => "focus",
800 TimerMode::ShortBreak => "shortbreak",
801 TimerMode::LongBreak => "longbreak",
802 TimerMode::Custom => "custom",
803 }
804}
805
806fn decode_timer_mode(s: &str) -> TimerMode {
807 match s {
808 "shortbreak" | "short_break" => TimerMode::ShortBreak,
809 "longbreak" | "long_break" => TimerMode::LongBreak,
810 "custom" => TimerMode::Custom,
811 _ => TimerMode::Focus,
812 }
813}
814
815fn encode_empty_queue(b: EmptyQueueBehavior) -> &'static str {
816 match b {
817 EmptyQueueBehavior::FreeFocus => "free-focus",
818 EmptyQueueBehavior::PauseTimer => "pause-timer",
819 EmptyQueueBehavior::AskEachTime => "ask",
820 }
821}
822
823fn decode_empty_queue(s: &str) -> Option<EmptyQueueBehavior> {
824 Some(match s {
825 "pause-timer" => EmptyQueueBehavior::PauseTimer,
826 "ask" => EmptyQueueBehavior::AskEachTime,
827 _ => EmptyQueueBehavior::FreeFocus,
828 })
829}
830
831fn encode_estimate_complete(b: EstimateCompleteBehavior) -> &'static str {
832 match b {
833 EstimateCompleteBehavior::Nudge => "nudge",
834 EstimateCompleteBehavior::None => "none",
835 EstimateCompleteBehavior::AutoDone => "auto-done",
836 }
837}
838
839fn decode_estimate_complete(s: &str) -> Option<EstimateCompleteBehavior> {
840 Some(match s {
841 "none" => EstimateCompleteBehavior::None,
842 "auto-done" => EstimateCompleteBehavior::AutoDone,
843 _ => EstimateCompleteBehavior::Nudge,
844 })
845}
846
847pub(crate) fn parse_datetime(s: &str) -> DateTime<Utc> {
848 DateTime::parse_from_rfc3339(s)
849 .map(|dt| dt.with_timezone(&Utc))
850 .unwrap_or_else(|_| Utc::now())
851}
852
853fn bool_str(v: bool) -> String {
854 if v { "1" } else { "0" }.to_string()
855}
856
857fn parse_bool(s: &str, default: bool) -> bool {
858 match s {
859 "1" | "true" | "yes" => true,
860 "0" | "false" | "no" => false,
861 _ => default,
862 }
863}
864
865pub(crate) fn read_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<u64> {
866 Ok(row.get::<_, i64>(idx)? as u64)
867}
868
869pub(crate) fn read_opt_u64(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Option<u64>> {
870 let value: Option<i64> = row.get(idx)?;
871 Ok(value.map(|id| id as u64))
872}
873
874fn parse_u32(s: &str, default: u32) -> u32 {
875 s.parse().unwrap_or(default)
876}
877
878fn parse_u64(s: &str, default: u64) -> u64 {
879 s.parse().unwrap_or(default)
880}
881
882fn opt_string(s: &str) -> Option<String> {
883 if s.is_empty() {
884 None
885 } else {
886 Some(s.to_string())
887 }
888}