Skip to main content

void/db/
mod.rs

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    /// Daily focus minutes keyed by `YYYY-MM-DD` (oldest first).
340    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    /// All days with logged focus/custom minutes from the database.
353    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
406// ── settings ─────────────────────────────────────────────────────────────────
407
408pub(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
571// ── tasks ────────────────────────────────────────────────────────────────────
572
573pub(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
763// ── encoding ─────────────────────────────────────────────────────────────────
764
765fn 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}