Skip to main content

work_tuimer/storage/
mod.rs

1use crate::models::{DayData, TimePoint, WorkRecord};
2use crate::timer::{TimerState, TimerStatus};
3use anyhow::{Context, Result, anyhow};
4use rusqlite::{Connection, OptionalExtension, Transaction, params};
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration as StdDuration, SystemTime};
9use time::format_description::well_known::Rfc3339;
10use time::macros::format_description;
11use time::{Date, OffsetDateTime};
12
13const DATABASE_FILE_NAME: &str = "work-tuimer.db";
14const LEGACY_RUNNING_TIMER_FILE_NAME: &str = "running_timer.json";
15const LEGACY_ACTIVE_TIMER_FILE_NAME: &str = "active_timer.json";
16const JSON_MIGRATION_META_KEY: &str = "migration.json_to_sqlite.v1";
17
18#[derive(Clone)]
19pub struct Storage {
20    repository: SqliteRepository,
21}
22
23#[derive(Clone)]
24struct SqliteRepository {
25    db_path: PathBuf,
26}
27
28/// High-level storage manager that provides transactional operations
29/// and automatic external modification tracking.
30pub struct StorageManager {
31    storage: Storage,
32    file_modified_times: HashMap<Date, Option<SystemTime>>,
33}
34
35pub struct StorageDiagnostics {
36    pub database_path: PathBuf,
37    pub migration_marker: Option<String>,
38    pub days_count: u64,
39    pub work_records_count: u64,
40    pub active_timer_present: bool,
41    pub legacy_day_json_files: u64,
42    pub legacy_timer_json_files: u64,
43}
44
45impl StorageManager {
46    /// Create a new StorageManager
47    pub fn new() -> Result<Self> {
48        Ok(StorageManager {
49            storage: Storage::new()?,
50            file_modified_times: HashMap::new(),
51        })
52    }
53
54    /// Create a new StorageManager with a custom directory (for testing)
55    #[doc(hidden)]
56    #[allow(dead_code)]
57    pub fn new_with_dir(data_dir: PathBuf) -> Result<Self> {
58        Ok(StorageManager {
59            storage: Storage::new_with_dir(data_dir)?,
60            file_modified_times: HashMap::new(),
61        })
62    }
63
64    /// Load day data with automatic revision tracking
65    pub fn load_with_tracking(&mut self, date: Date) -> Result<DayData> {
66        let data = self.storage.load(&date)?;
67        let modified_time = self.storage.try_get_file_modified_time(&date)?;
68        self.file_modified_times.insert(date, modified_time);
69        Ok(data)
70    }
71
72    /// Check if day data changed externally and reload if needed.
73    /// Returns Some(DayData) if data was modified and reloaded, None if no change.
74    pub fn check_and_reload(&mut self, date: Date) -> Result<Option<DayData>> {
75        let current_modified = self.storage.try_get_file_modified_time(&date)?;
76
77        let is_tracked = self.file_modified_times.contains_key(&date);
78        if !is_tracked {
79            let data = self.storage.load(&date)?;
80            self.file_modified_times.insert(date, current_modified);
81            return Ok(Some(data));
82        }
83
84        let last_known = self.file_modified_times.get(&date).copied().flatten();
85        // We only need inequality detection here. We intentionally do not rely
86        // on ordering semantics across processes.
87        if current_modified != last_known {
88            let data = self.storage.load(&date)?;
89            self.file_modified_times.insert(date, current_modified);
90            Ok(Some(data))
91        } else {
92            Ok(None)
93        }
94    }
95
96    /// Add a new work record (transactional: load -> add -> save -> track)
97    #[allow(dead_code)]
98    pub fn add_record(&mut self, date: Date, record: WorkRecord) -> Result<()> {
99        let mut day_data = self.storage.load(&date)?;
100        day_data.add_record(record);
101        self.storage.save(&day_data)?;
102
103        let modified_time = self.storage.try_get_file_modified_time(&date)?;
104        self.file_modified_times.insert(date, modified_time);
105
106        Ok(())
107    }
108
109    /// Update an existing work record (transactional: load -> update -> save -> track)
110    #[allow(dead_code)]
111    pub fn update_record(&mut self, date: Date, record: WorkRecord) -> Result<()> {
112        let mut day_data = self.storage.load(&date)?;
113        day_data.add_record(record);
114        self.storage.save(&day_data)?;
115
116        let modified_time = self.storage.try_get_file_modified_time(&date)?;
117        self.file_modified_times.insert(date, modified_time);
118
119        Ok(())
120    }
121
122    /// Remove a work record by ID (transactional: load -> remove -> save -> track)
123    /// Returns the removed record if found.
124    #[allow(dead_code)]
125    pub fn remove_record(&mut self, date: Date, id: u32) -> Result<WorkRecord> {
126        let mut day_data = self.storage.load(&date)?;
127
128        let record = day_data
129            .work_records
130            .remove(&id)
131            .context(format!("Record with ID {} not found", id))?;
132
133        self.storage.save(&day_data)?;
134
135        let modified_time = self.storage.try_get_file_modified_time(&date)?;
136        self.file_modified_times.insert(date, modified_time);
137
138        Ok(record)
139    }
140
141    /// Save day data and update tracking
142    pub fn save(&mut self, day_data: &DayData) -> Result<()> {
143        self.storage.save(day_data)?;
144
145        let modified_time = self.storage.try_get_file_modified_time(&day_data.date)?;
146        self.file_modified_times
147            .insert(day_data.date, modified_time);
148
149        Ok(())
150    }
151
152    /// Get the last known modification token for a date
153    pub fn get_last_modified(&self, date: &Date) -> Option<SystemTime> {
154        self.file_modified_times.get(date).copied().flatten()
155    }
156
157    #[allow(dead_code)]
158    pub fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
159        self.storage.save_active_timer(timer)
160    }
161
162    pub fn load_active_timer(&self) -> Result<Option<TimerState>> {
163        self.storage.load_active_timer()
164    }
165
166    #[allow(dead_code)]
167    pub fn clear_active_timer(&self) -> Result<()> {
168        self.storage.clear_active_timer()
169    }
170
171    fn create_timer_manager(&self) -> crate::timer::TimerManager {
172        crate::timer::TimerManager::new(self.storage.clone())
173    }
174
175    pub fn start_timer(
176        &self,
177        task_name: String,
178        description: Option<String>,
179        project: Option<String>,
180        customer: Option<String>,
181        source_record_id: Option<u32>,
182        source_record_date: Option<time::Date>,
183    ) -> Result<TimerState> {
184        let timer_manager = self.create_timer_manager();
185        timer_manager.start(
186            task_name,
187            description,
188            project,
189            customer,
190            source_record_id,
191            source_record_date,
192        )
193    }
194
195    pub fn stop_timer(&self) -> Result<crate::models::WorkRecord> {
196        let timer_manager = self.create_timer_manager();
197        timer_manager.stop()
198    }
199
200    pub fn pause_timer(&self) -> Result<TimerState> {
201        let timer_manager = self.create_timer_manager();
202        timer_manager.pause()
203    }
204
205    pub fn resume_timer(&self) -> Result<TimerState> {
206        let timer_manager = self.create_timer_manager();
207        timer_manager.resume()
208    }
209
210    #[allow(dead_code)]
211    pub fn get_timer_elapsed(&self, timer: &TimerState) -> std::time::Duration {
212        let timer_manager = self.create_timer_manager();
213        timer_manager.get_elapsed_duration(timer)
214    }
215}
216
217impl Storage {
218    pub fn new() -> Result<Self> {
219        let data_dir = Self::get_data_directory()?;
220        Self::new_with_dir(data_dir)
221    }
222
223    /// Create a new Storage with a custom directory (for testing)
224    #[doc(hidden)]
225    #[allow(dead_code)]
226    pub fn new_with_dir(data_dir: PathBuf) -> Result<Self> {
227        fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
228
229        let db_path = data_dir.join(DATABASE_FILE_NAME);
230        let repository = SqliteRepository::new(db_path);
231        repository.initialize(&data_dir)?;
232
233        Ok(Storage { repository })
234    }
235
236    fn get_data_directory() -> Result<PathBuf> {
237        if let Some(data_dir) = dirs::data_local_dir() {
238            let app_dir = data_dir.join("work-tuimer");
239            if fs::create_dir_all(&app_dir).is_ok() {
240                return Ok(app_dir);
241            }
242        }
243
244        let local_data = PathBuf::from("./data");
245        if fs::create_dir_all(&local_data).is_ok() {
246            return Ok(local_data);
247        }
248
249        anyhow::bail!("Failed to create data directory in system location or ./data")
250    }
251
252    pub fn load(&self, date: &Date) -> Result<DayData> {
253        self.repository.load_day(date)
254    }
255
256    pub fn save(&self, day_data: &DayData) -> Result<()> {
257        self.repository.save_day(day_data)
258    }
259
260    /// Get a synthetic monotonic token derived from day revision state.
261    /// Returns None if the day has never been written.
262    ///
263    /// This is not a wall-clock timestamp; it is used only for equality/
264    /// inequality change detection.
265    #[allow(dead_code)]
266    pub fn get_file_modified_time(&self, date: &Date) -> Option<SystemTime> {
267        match self.try_get_file_modified_time(date) {
268            Ok(token) => token,
269            Err(err) => {
270                eprintln!(
271                    "Failed to read day revision token for {} from storage: {err:#}",
272                    date
273                );
274                None
275            }
276        }
277    }
278
279    pub fn try_get_file_modified_time(&self, date: &Date) -> Result<Option<SystemTime>> {
280        self.repository.day_revision_token(date)
281    }
282
283    pub fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
284        self.repository.save_active_timer(timer)
285    }
286
287    pub fn load_active_timer(&self) -> Result<Option<TimerState>> {
288        self.repository.load_active_timer()
289    }
290
291    pub fn clear_active_timer(&self) -> Result<()> {
292        self.repository.clear_active_timer()
293    }
294
295    pub fn diagnostics(&self) -> Result<StorageDiagnostics> {
296        self.repository.diagnostics()
297    }
298
299    pub fn list_dates_with_records(&self) -> Result<Vec<Date>> {
300        self.repository.list_dates_with_records()
301    }
302
303    #[cfg(test)]
304    fn get_db_path(&self) -> PathBuf {
305        self.repository.db_path.clone()
306    }
307}
308
309impl SqliteRepository {
310    fn new(db_path: PathBuf) -> Self {
311        Self { db_path }
312    }
313
314    fn initialize(&self, data_dir: &Path) -> Result<()> {
315        let conn = self.open_connection()?;
316        Self::apply_database_pragmas(&conn)?;
317        Self::initialize_schema(&conn)?;
318        Self::apply_schema_migrations(&conn)?;
319        drop(conn);
320
321        self
322            .migrate_from_legacy_json_if_needed(data_dir)
323            .context("Failed to migrate legacy JSON into SQLite. Fix or remove malformed legacy JSON files and restart")
324    }
325
326    fn open_connection(&self) -> Result<Connection> {
327        let conn = Connection::open(&self.db_path).context(format!(
328            "Failed to open SQLite database at {:?}",
329            self.db_path
330        ))?;
331        conn.execute_batch("PRAGMA foreign_keys = ON;")
332            .context("Failed to enable SQLite foreign key support")?;
333        Ok(conn)
334    }
335
336    fn apply_database_pragmas(conn: &Connection) -> Result<()> {
337        conn.execute_batch(
338            "
339            PRAGMA journal_mode = WAL;
340            PRAGMA synchronous = NORMAL;
341            ",
342        )
343        .context("Failed to configure SQLite pragmas")?;
344        Ok(())
345    }
346
347    fn initialize_schema(conn: &Connection) -> Result<()> {
348        conn.execute_batch(
349            "
350            CREATE TABLE IF NOT EXISTS meta (
351                key TEXT PRIMARY KEY,
352                value TEXT NOT NULL
353            );
354
355            CREATE TABLE IF NOT EXISTS day_meta (
356                date TEXT PRIMARY KEY,
357                last_id INTEGER NOT NULL DEFAULT 0 CHECK (last_id >= 0),
358                revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0)
359            );
360
361            CREATE TABLE IF NOT EXISTS work_records (
362                date TEXT NOT NULL,
363                id INTEGER NOT NULL,
364                name TEXT NOT NULL,
365                start_minutes INTEGER NOT NULL,
366                end_minutes INTEGER NOT NULL,
367                total_minutes INTEGER NOT NULL,
368                project TEXT NOT NULL DEFAULT '',
369                customer TEXT NOT NULL DEFAULT '',
370                description TEXT NOT NULL DEFAULT '',
371                PRIMARY KEY (date, id),
372                FOREIGN KEY (date) REFERENCES day_meta(date) ON DELETE CASCADE
373            );
374
375            CREATE INDEX IF NOT EXISTS idx_work_records_date_start
376                ON work_records(date, start_minutes);
377
378            CREATE TABLE IF NOT EXISTS active_timer (
379                singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
380                id INTEGER,
381                task_name TEXT NOT NULL,
382                description TEXT,
383                project TEXT,
384                customer TEXT,
385                start_time TEXT NOT NULL,
386                end_time TEXT,
387                date TEXT NOT NULL,
388                status TEXT NOT NULL CHECK (status IN ('running', 'paused', 'stopped')),
389                paused_duration_secs INTEGER NOT NULL CHECK (paused_duration_secs >= 0),
390                paused_at TEXT,
391                created_at TEXT NOT NULL,
392                updated_at TEXT NOT NULL,
393                source_record_id INTEGER,
394                source_record_date TEXT
395            );
396            ",
397        )
398        .context("Failed to initialize SQLite schema")?;
399
400        Ok(())
401    }
402
403    fn apply_schema_migrations(conn: &Connection) -> Result<()> {
404        Self::ensure_column_exists(conn, "work_records", "project", "TEXT NOT NULL DEFAULT ''")?;
405        Self::ensure_column_exists(conn, "work_records", "customer", "TEXT NOT NULL DEFAULT ''")?;
406        Self::ensure_column_exists(conn, "active_timer", "project", "TEXT")?;
407        Self::ensure_column_exists(conn, "active_timer", "customer", "TEXT")?;
408        Ok(())
409    }
410
411    fn ensure_column_exists(
412        conn: &Connection,
413        table: &str,
414        column: &str,
415        definition: &str,
416    ) -> Result<()> {
417        if Self::has_column(conn, table, column)? {
418            return Ok(());
419        }
420
421        let alter_sql = format!("ALTER TABLE {table} ADD COLUMN {column} {definition}");
422        conn.execute(&alter_sql, [])
423            .context(format!("Failed to add column {column} to {table}"))?;
424        Ok(())
425    }
426
427    fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
428        let pragma_sql = format!("PRAGMA table_info({table})");
429        let mut stmt = conn
430            .prepare(&pragma_sql)
431            .context(format!("Failed to inspect table schema for {table}"))?;
432        let mut rows = stmt.query([]).context("Failed to query table_info")?;
433
434        while let Some(row) = rows.next().context("Failed to read table_info row")? {
435            let column_name = row.get::<_, String>(1)?;
436            if column_name == column {
437                return Ok(true);
438            }
439        }
440
441        Ok(false)
442    }
443
444    fn load_day(&self, date: &Date) -> Result<DayData> {
445        let conn = self.open_connection()?;
446        let date_key = format_date(*date);
447
448        let last_id_opt = conn
449            .query_row(
450                "SELECT last_id FROM day_meta WHERE date = ?1",
451                params![date_key.clone()],
452                |row| row.get::<_, i64>(0),
453            )
454            .optional()
455            .context("Failed to query day metadata")?;
456
457        let Some(last_id_raw) = last_id_opt else {
458            return Ok(DayData::new(*date));
459        };
460
461        let last_id = i64_to_u32(last_id_raw, "day_meta.last_id")?;
462
463        let mut day_data = DayData {
464            date: *date,
465            last_id,
466            work_records: HashMap::new(),
467        };
468
469        let mut stmt = conn
470            .prepare(
471                "
472                SELECT id, name, start_minutes, end_minutes, total_minutes, project, customer, description
473                FROM work_records
474                WHERE date = ?1
475                ORDER BY id
476                ",
477            )
478            .context("Failed to prepare work records query")?;
479
480        let mut rows = stmt
481            .query(params![date_key])
482            .context("Failed to query work records")?;
483
484        while let Some(row) = rows.next().context("Failed to read work record row")? {
485            let id = i64_to_u32(row.get::<_, i64>(0)?, "work_records.id")?;
486            let name = row.get::<_, String>(1)?;
487            let start_minutes = i64_to_u32(row.get::<_, i64>(2)?, "work_records.start_minutes")?;
488            let end_minutes = i64_to_u32(row.get::<_, i64>(3)?, "work_records.end_minutes")?;
489            let total_minutes = i64_to_u32(row.get::<_, i64>(4)?, "work_records.total_minutes")?;
490            let project = row.get::<_, Option<String>>(5)?.unwrap_or_default();
491            let customer = row.get::<_, Option<String>>(6)?.unwrap_or_default();
492            let description = row.get::<_, Option<String>>(7)?.unwrap_or_default();
493
494            let start = TimePoint::from_minutes_since_midnight(start_minutes)
495                .map_err(|e| anyhow!(e))
496                .context("Invalid start_minutes value in database")?;
497            let end = TimePoint::from_minutes_since_midnight(end_minutes)
498                .map_err(|e| anyhow!(e))
499                .context("Invalid end_minutes value in database")?;
500
501            let record = WorkRecord {
502                id,
503                name,
504                start,
505                end,
506                total_minutes,
507                project,
508                customer,
509                description,
510            };
511
512            day_data.work_records.insert(id, record);
513        }
514
515        Ok(day_data)
516    }
517
518    fn save_day(&self, day_data: &DayData) -> Result<()> {
519        let mut conn = self.open_connection()?;
520        let tx = conn
521            .transaction()
522            .context("Failed to begin day save transaction")?;
523
524        Self::write_day_data_tx(&tx, day_data, true)?;
525
526        tx.commit()
527            .context("Failed to commit day save transaction")?;
528        Ok(())
529    }
530
531    fn day_revision_token(&self, date: &Date) -> Result<Option<SystemTime>> {
532        let conn = self.open_connection()?;
533        let date_key = format_date(*date);
534
535        let revision_opt = conn
536            .query_row(
537                "SELECT revision FROM day_meta WHERE date = ?1",
538                params![date_key],
539                |row| row.get::<_, i64>(0),
540            )
541            .optional()
542            .context("Failed to query day revision")?;
543
544        match revision_opt {
545            None => Ok(None),
546            Some(revision) => {
547                if revision < 0 {
548                    anyhow::bail!("Invalid negative revision in database: {}", revision);
549                }
550                // Convert revision counter into a synthetic token. We only use
551                // this value as a monotonic change marker, not as a real time.
552                let token = SystemTime::UNIX_EPOCH
553                    .checked_add(StdDuration::from_secs(revision as u64))
554                    .context("Failed to convert revision to SystemTime")?;
555                Ok(Some(token))
556            }
557        }
558    }
559
560    fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
561        let mut conn = self.open_connection()?;
562        let tx = conn
563            .transaction()
564            .context("Failed to begin active timer transaction")?;
565
566        Self::save_active_timer_tx(&tx, timer)?;
567        tx.commit()
568            .context("Failed to commit active timer transaction")?;
569
570        Ok(())
571    }
572
573    fn load_active_timer(&self) -> Result<Option<TimerState>> {
574        let conn = self.open_connection()?;
575
576        let timer_row = conn
577            .query_row(
578                "
579                SELECT
580                    id,
581                    task_name,
582                    description,
583                    project,
584                    customer,
585                    start_time,
586                    end_time,
587                    date,
588                    status,
589                    paused_duration_secs,
590                    paused_at,
591                    created_at,
592                    updated_at,
593                    source_record_id,
594                    source_record_date
595                FROM active_timer
596                WHERE singleton_id = 1
597                ",
598                [],
599                |row| {
600                    Ok((
601                        row.get::<_, Option<i64>>(0)?,
602                        row.get::<_, String>(1)?,
603                        row.get::<_, Option<String>>(2)?,
604                        row.get::<_, Option<String>>(3)?,
605                        row.get::<_, Option<String>>(4)?,
606                        row.get::<_, String>(5)?,
607                        row.get::<_, Option<String>>(6)?,
608                        row.get::<_, String>(7)?,
609                        row.get::<_, String>(8)?,
610                        row.get::<_, i64>(9)?,
611                        row.get::<_, Option<String>>(10)?,
612                        row.get::<_, String>(11)?,
613                        row.get::<_, String>(12)?,
614                        row.get::<_, Option<i64>>(13)?,
615                        row.get::<_, Option<String>>(14)?,
616                    ))
617                },
618            )
619            .optional()
620            .context("Failed to query active timer")?;
621
622        let Some(row) = timer_row else {
623            return Ok(None);
624        };
625
626        let id = row
627            .0
628            .map(|v| i64_to_u32(v, "active_timer.id"))
629            .transpose()?;
630        let task_name = row.1;
631        let description = row.2;
632        let project = row.3;
633        let customer = row.4;
634        let start_time = parse_datetime(&row.5, "active_timer.start_time")?;
635        let end_time = row
636            .6
637            .as_deref()
638            .map(|v| parse_datetime(v, "active_timer.end_time"))
639            .transpose()?;
640        let date = parse_date(&row.7).context("Invalid active_timer.date")?;
641        let status = parse_timer_status(&row.8)?;
642        let paused_duration_secs = row.9;
643        let paused_at = row
644            .10
645            .as_deref()
646            .map(|v| parse_datetime(v, "active_timer.paused_at"))
647            .transpose()?;
648        let created_at = parse_datetime(&row.11, "active_timer.created_at")?;
649        let updated_at = parse_datetime(&row.12, "active_timer.updated_at")?;
650        let source_record_id = row
651            .13
652            .map(|v| i64_to_u32(v, "active_timer.source_record_id"))
653            .transpose()?;
654        let source_record_date = row
655            .14
656            .as_deref()
657            .map(parse_date)
658            .transpose()
659            .context("Invalid active_timer.source_record_date")?;
660
661        Ok(Some(TimerState {
662            id,
663            task_name,
664            description,
665            project,
666            customer,
667            start_time,
668            end_time,
669            date,
670            status,
671            paused_duration_secs,
672            paused_at,
673            created_at,
674            updated_at,
675            source_record_id,
676            source_record_date,
677        }))
678    }
679
680    fn clear_active_timer(&self) -> Result<()> {
681        let conn = self.open_connection()?;
682        conn.execute("DELETE FROM active_timer WHERE singleton_id = 1", [])
683            .context("Failed to clear active timer")?;
684        Ok(())
685    }
686
687    fn diagnostics(&self) -> Result<StorageDiagnostics> {
688        let conn = self.open_connection()?;
689
690        let migration_marker = conn
691            .query_row(
692                "SELECT value FROM meta WHERE key = ?1",
693                params![JSON_MIGRATION_META_KEY],
694                |row| row.get::<_, String>(0),
695            )
696            .optional()
697            .context("Failed to query migration marker")?;
698
699        let days_count = i64_to_u64(
700            conn.query_row("SELECT COUNT(*) FROM day_meta", [], |row| {
701                row.get::<_, i64>(0)
702            })
703            .context("Failed to count day rows")?,
704            "day_meta count",
705        )?;
706
707        let work_records_count = i64_to_u64(
708            conn.query_row("SELECT COUNT(*) FROM work_records", [], |row| {
709                row.get::<_, i64>(0)
710            })
711            .context("Failed to count work record rows")?,
712            "work_records count",
713        )?;
714
715        let active_timer_present = conn
716            .query_row(
717                "SELECT 1 FROM active_timer WHERE singleton_id = 1 LIMIT 1",
718                [],
719                |_row| Ok(()),
720            )
721            .optional()
722            .context("Failed to check active timer existence")?
723            .is_some();
724
725        let database_path = self.db_path.clone();
726        let data_dir = database_path
727            .parent()
728            .context("Database path has no parent directory")?;
729
730        let (legacy_day_json_files, legacy_timer_json_files) =
731            Self::count_legacy_json_files(data_dir)?;
732
733        Ok(StorageDiagnostics {
734            database_path,
735            migration_marker,
736            days_count,
737            work_records_count,
738            active_timer_present,
739            legacy_day_json_files,
740            legacy_timer_json_files,
741        })
742    }
743
744    fn list_dates_with_records(&self) -> Result<Vec<Date>> {
745        let conn = self.open_connection()?;
746        let mut stmt = conn
747            .prepare(
748                "
749                SELECT DISTINCT date
750                FROM work_records
751                ORDER BY date
752                ",
753            )
754            .context("Failed to prepare query for exportable dates")?;
755
756        let mut rows = stmt
757            .query([])
758            .context("Failed to query dates with work records")?;
759        let mut dates = Vec::new();
760
761        while let Some(row) = rows.next().context("Failed to read date row")? {
762            let raw_date = row.get::<_, String>(0)?;
763            let date = parse_date(&raw_date)
764                .context(format!("Failed to parse date '{}' from storage", raw_date))?;
765            dates.push(date);
766        }
767
768        Ok(dates)
769    }
770
771    fn count_legacy_json_files(data_dir: &Path) -> Result<(u64, u64)> {
772        let entries = fs::read_dir(data_dir).context("Failed to read data directory")?;
773
774        let mut day_files_count = 0_u64;
775        let mut timer_files_count = 0_u64;
776
777        for entry in entries {
778            let entry = entry.context("Failed to read directory entry")?;
779            let path = entry.path();
780
781            if !path.is_file() {
782                continue;
783            }
784
785            let Some(file_name_os) = path.file_name() else {
786                continue;
787            };
788            let Some(file_name) = file_name_os.to_str() else {
789                continue;
790            };
791
792            if !file_name.ends_with(".json") {
793                continue;
794            }
795
796            if matches!(
797                file_name,
798                LEGACY_RUNNING_TIMER_FILE_NAME | LEGACY_ACTIVE_TIMER_FILE_NAME
799            ) {
800                timer_files_count += 1;
801                continue;
802            }
803
804            let Some(date_part) = file_name.strip_suffix(".json") else {
805                continue;
806            };
807
808            if parse_date(date_part).is_ok() {
809                day_files_count += 1;
810            }
811        }
812
813        Ok((day_files_count, timer_files_count))
814    }
815
816    fn migrate_from_legacy_json_if_needed(&self, data_dir: &Path) -> Result<()> {
817        let mut conn = self.open_connection()?;
818        let tx = conn
819            .transaction()
820            .context("Failed to begin JSON migration transaction")?;
821
822        if Self::get_meta_value_tx(&tx, JSON_MIGRATION_META_KEY)?.is_some() {
823            tx.commit()
824                .context("Failed to finalize already-completed migration transaction")?;
825            return Ok(());
826        }
827
828        Self::import_legacy_day_files_tx(&tx, data_dir)?;
829        Self::import_legacy_timer_tx(&tx, data_dir)?;
830        Self::set_meta_value_tx(&tx, JSON_MIGRATION_META_KEY, "done")?;
831
832        tx.commit().context("Failed to commit JSON migration")?;
833        Ok(())
834    }
835
836    fn write_day_data_tx(
837        tx: &Transaction<'_>,
838        day_data: &DayData,
839        increment_revision: bool,
840    ) -> Result<()> {
841        let date_key = format_date(day_data.date);
842
843        tx.execute(
844            "
845            INSERT INTO day_meta(date, last_id, revision)
846            VALUES (?1, 0, 0)
847            ON CONFLICT(date) DO NOTHING
848            ",
849            params![date_key.clone()],
850        )
851        .context("Failed to upsert day_meta row")?;
852
853        tx.execute(
854            "DELETE FROM work_records WHERE date = ?1",
855            params![date_key.clone()],
856        )
857        .context("Failed to clear existing day work records")?;
858
859        {
860            let mut stmt = tx
861                .prepare(
862                    "
863                    INSERT INTO work_records (
864                        date,
865                        id,
866                        name,
867                        start_minutes,
868                        end_minutes,
869                        total_minutes,
870                        project,
871                        customer,
872                        description
873                    )
874                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
875                    ",
876                )
877                .context("Failed to prepare work record insert statement")?;
878
879            for record in day_data.work_records.values() {
880                stmt.execute(params![
881                    date_key.clone(),
882                    i64::from(record.id),
883                    &record.name,
884                    i64::from(record.start.to_minutes_since_midnight()),
885                    i64::from(record.end.to_minutes_since_midnight()),
886                    i64::from(record.total_minutes),
887                    &record.project,
888                    &record.customer,
889                    &record.description,
890                ])
891                .context("Failed to insert work record")?;
892            }
893        }
894
895        if increment_revision {
896            // Revision is an i64 counter. It would require an impractical number
897            // of writes to overflow, and serves only as a monotonic change token.
898            tx.execute(
899                "
900                UPDATE day_meta
901                SET last_id = ?1,
902                    revision = revision + 1
903                WHERE date = ?2
904                ",
905                params![i64::from(day_data.last_id), date_key],
906            )
907            .context("Failed to update day_meta with incremented revision")?;
908        } else {
909            // Migration initializes revision to at least 1 so downstream change
910            // detection has a stable non-zero token from first persisted state.
911            tx.execute(
912                "
913                UPDATE day_meta
914                SET last_id = ?1,
915                    revision = CASE WHEN revision = 0 THEN 1 ELSE revision END
916                WHERE date = ?2
917                ",
918                params![i64::from(day_data.last_id), date_key],
919            )
920            .context("Failed to update day_meta during migration")?;
921        }
922
923        Ok(())
924    }
925
926    fn save_active_timer_tx(tx: &Transaction<'_>, timer: &TimerState) -> Result<()> {
927        let id = timer.id.map(i64::from);
928        let start_time = format_datetime(timer.start_time)?;
929        let end_time = timer.end_time.map(format_datetime).transpose()?;
930        let date = format_date(timer.date);
931        let status = timer_status_to_str(timer.status);
932        let project = timer.project.as_deref();
933        let customer = timer.customer.as_deref();
934        let paused_at = timer.paused_at.map(format_datetime).transpose()?;
935        let created_at = format_datetime(timer.created_at)?;
936        let updated_at = format_datetime(timer.updated_at)?;
937        let source_record_id = timer.source_record_id.map(i64::from);
938        let source_record_date = timer.source_record_date.map(format_date);
939
940        tx.execute(
941            "
942            INSERT INTO active_timer (
943                singleton_id,
944                id,
945                task_name,
946                description,
947                project,
948                customer,
949                start_time,
950                end_time,
951                date,
952                status,
953                paused_duration_secs,
954                paused_at,
955                created_at,
956                updated_at,
957                source_record_id,
958                source_record_date
959            )
960            VALUES (
961                1,
962                ?1,
963                ?2,
964                ?3,
965                ?4,
966                ?5,
967                ?6,
968                ?7,
969                ?8,
970                ?9,
971                ?10,
972                ?11,
973                ?12,
974                ?13,
975                ?14,
976                ?15
977            )
978            ON CONFLICT(singleton_id) DO UPDATE SET
979                id = excluded.id,
980                task_name = excluded.task_name,
981                description = excluded.description,
982                project = excluded.project,
983                customer = excluded.customer,
984                start_time = excluded.start_time,
985                end_time = excluded.end_time,
986                date = excluded.date,
987                status = excluded.status,
988                paused_duration_secs = excluded.paused_duration_secs,
989                paused_at = excluded.paused_at,
990                created_at = excluded.created_at,
991                updated_at = excluded.updated_at,
992                source_record_id = excluded.source_record_id,
993                source_record_date = excluded.source_record_date
994            ",
995            params![
996                id,
997                &timer.task_name,
998                timer.description.as_deref(),
999                project,
1000                customer,
1001                start_time,
1002                end_time,
1003                date,
1004                status,
1005                timer.paused_duration_secs,
1006                paused_at,
1007                created_at,
1008                updated_at,
1009                source_record_id,
1010                source_record_date,
1011            ],
1012        )
1013        .context("Failed to upsert active timer")?;
1014
1015        Ok(())
1016    }
1017
1018    fn get_meta_value_tx(tx: &Transaction<'_>, key: &str) -> Result<Option<String>> {
1019        tx.query_row(
1020            "SELECT value FROM meta WHERE key = ?1",
1021            params![key],
1022            |row| row.get::<_, String>(0),
1023        )
1024        .optional()
1025        .context(format!("Failed to query meta key '{}'", key))
1026    }
1027
1028    fn set_meta_value_tx(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
1029        tx.execute(
1030            "
1031            INSERT INTO meta(key, value)
1032            VALUES (?1, ?2)
1033            ON CONFLICT(key) DO UPDATE SET value = excluded.value
1034            ",
1035            params![key, value],
1036        )
1037        .context(format!("Failed to upsert meta key '{}'", key))?;
1038        Ok(())
1039    }
1040
1041    fn day_exists_tx(tx: &Transaction<'_>, date_key: &str) -> Result<bool> {
1042        let exists = tx
1043            .query_row(
1044                "SELECT 1 FROM day_meta WHERE date = ?1 LIMIT 1",
1045                params![date_key],
1046                |_row| Ok(()),
1047            )
1048            .optional()
1049            .context("Failed to check day existence")?
1050            .is_some();
1051        Ok(exists)
1052    }
1053
1054    fn import_legacy_day_files_tx(tx: &Transaction<'_>, data_dir: &Path) -> Result<()> {
1055        let entries = fs::read_dir(data_dir).context("Failed to read data directory")?;
1056        let mut paths = Vec::new();
1057
1058        for entry in entries {
1059            let entry = entry.context("Failed to read directory entry")?;
1060            paths.push(entry.path());
1061        }
1062
1063        paths.sort();
1064
1065        for path in paths {
1066            if !path.is_file() {
1067                continue;
1068            }
1069
1070            let Some(file_name_os) = path.file_name() else {
1071                continue;
1072            };
1073            let Some(file_name) = file_name_os.to_str() else {
1074                continue;
1075            };
1076
1077            if !file_name.ends_with(".json") {
1078                continue;
1079            }
1080
1081            if matches!(
1082                file_name,
1083                LEGACY_RUNNING_TIMER_FILE_NAME | LEGACY_ACTIVE_TIMER_FILE_NAME
1084            ) {
1085                continue;
1086            }
1087
1088            let Some(date_part) = file_name.strip_suffix(".json") else {
1089                continue;
1090            };
1091
1092            let parsed_file_date = match parse_date(date_part) {
1093                Ok(date) => date,
1094                Err(_) => continue,
1095            };
1096
1097            let date_key = format_date(parsed_file_date);
1098            if Self::day_exists_tx(tx, &date_key)? {
1099                eprintln!(
1100                    "Skipping legacy JSON import for {} from {:?}: data already exists in SQLite",
1101                    date_key, path
1102                );
1103                continue;
1104            }
1105
1106            let contents = fs::read_to_string(&path)
1107                .context(format!("Failed to read legacy JSON file: {:?}", path))?;
1108            let day_data: DayData = serde_json::from_str(&contents)
1109                .context(format!("Failed to parse legacy day JSON: {:?}", path))?;
1110
1111            Self::write_day_data_tx(tx, &day_data, false)?;
1112        }
1113
1114        Ok(())
1115    }
1116
1117    fn import_legacy_timer_tx(tx: &Transaction<'_>, data_dir: &Path) -> Result<()> {
1118        let timer_exists = tx
1119            .query_row(
1120                "SELECT 1 FROM active_timer WHERE singleton_id = 1 LIMIT 1",
1121                [],
1122                |_row| Ok(()),
1123            )
1124            .optional()
1125            .context("Failed to check existing active timer")?
1126            .is_some();
1127
1128        if timer_exists {
1129            return Ok(());
1130        }
1131
1132        let running_timer_path = data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME);
1133        let active_timer_path = data_dir.join(LEGACY_ACTIVE_TIMER_FILE_NAME);
1134
1135        if running_timer_path.exists() && active_timer_path.exists() {
1136            eprintln!(
1137                "Found both legacy timer files ({:?}, {:?}); preferring {:?}",
1138                running_timer_path, active_timer_path, running_timer_path
1139            );
1140        }
1141
1142        let timer_paths = [running_timer_path, active_timer_path];
1143
1144        for path in timer_paths {
1145            if !path.exists() {
1146                continue;
1147            }
1148
1149            let contents = fs::read_to_string(&path)
1150                .context(format!("Failed to read legacy timer JSON: {:?}", path))?;
1151            let timer: TimerState = serde_json::from_str(&contents)
1152                .context(format!("Failed to parse legacy timer JSON: {:?}", path))?;
1153
1154            Self::save_active_timer_tx(tx, &timer)?;
1155            break;
1156        }
1157
1158        Ok(())
1159    }
1160}
1161
1162fn format_date(date: Date) -> String {
1163    format!(
1164        "{:04}-{:02}-{:02}",
1165        date.year(),
1166        date.month() as u8,
1167        date.day()
1168    )
1169}
1170
1171fn parse_date(value: &str) -> Result<Date> {
1172    Date::parse(value, format_description!("[year]-[month]-[day]"))
1173        .context(format!("Invalid date: '{}'. Expected YYYY-MM-DD", value))
1174}
1175
1176fn format_datetime(value: OffsetDateTime) -> Result<String> {
1177    value
1178        .format(&Rfc3339)
1179        .context("Failed to serialize datetime as RFC3339")
1180}
1181
1182fn parse_datetime(value: &str, field: &str) -> Result<OffsetDateTime> {
1183    OffsetDateTime::parse(value, &Rfc3339).context(format!(
1184        "Invalid RFC3339 datetime in {}: '{}'",
1185        field, value
1186    ))
1187}
1188
1189fn timer_status_to_str(status: TimerStatus) -> &'static str {
1190    match status {
1191        TimerStatus::Running => "running",
1192        TimerStatus::Paused => "paused",
1193        TimerStatus::Stopped => "stopped",
1194    }
1195}
1196
1197fn parse_timer_status(value: &str) -> Result<TimerStatus> {
1198    match value {
1199        "running" => Ok(TimerStatus::Running),
1200        "paused" => Ok(TimerStatus::Paused),
1201        "stopped" => Ok(TimerStatus::Stopped),
1202        _ => anyhow::bail!("Invalid timer status in database: {}", value),
1203    }
1204}
1205
1206fn i64_to_u32(value: i64, field_name: &str) -> Result<u32> {
1207    u32::try_from(value).context(format!(
1208        "Invalid value in {}: {} (must fit in u32)",
1209        field_name, value
1210    ))
1211}
1212
1213fn i64_to_u64(value: i64, field_name: &str) -> Result<u64> {
1214    u64::try_from(value).context(format!(
1215        "Invalid value in {}: {} (must fit in u64)",
1216        field_name, value
1217    ))
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use tempfile::TempDir;
1224
1225    fn create_test_date() -> Date {
1226        Date::from_calendar_date(2025, time::Month::November, 6).unwrap()
1227    }
1228
1229    fn create_test_record(id: u32, name: &str) -> WorkRecord {
1230        let start = TimePoint::new(9, 0).unwrap();
1231        let end = TimePoint::new(17, 0).unwrap();
1232        WorkRecord::new(id, name.to_string(), start, end)
1233    }
1234
1235    fn create_test_timer() -> TimerState {
1236        let now = OffsetDateTime::now_utc();
1237        TimerState {
1238            id: None,
1239            task_name: "Test Timer".to_string(),
1240            description: Some("Timer description".to_string()),
1241            project: Some("Platform".to_string()),
1242            customer: Some("ACME".to_string()),
1243            start_time: now,
1244            end_time: None,
1245            date: now.date(),
1246            status: TimerStatus::Running,
1247            paused_duration_secs: 0,
1248            paused_at: None,
1249            created_at: now,
1250            updated_at: now,
1251            source_record_id: None,
1252            source_record_date: None,
1253        }
1254    }
1255
1256    #[test]
1257    fn test_new_storage_with_temp_dir_initializes_database() {
1258        let temp_dir = TempDir::new().unwrap();
1259        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1260
1261        assert!(temp_dir.path().exists());
1262        assert!(storage.get_db_path().exists());
1263    }
1264
1265    #[test]
1266    fn test_save_and_load_day_data_round_trip() {
1267        let temp_dir = TempDir::new().unwrap();
1268        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1269        let date = create_test_date();
1270
1271        let mut day_data = DayData::new(date);
1272        day_data.add_record(create_test_record(1, "Coding"));
1273        day_data.add_record(create_test_record(2, "Meeting"));
1274
1275        storage.save(&day_data).unwrap();
1276        let loaded = storage.load(&date).unwrap();
1277
1278        assert_eq!(loaded.date, date);
1279        assert_eq!(loaded.last_id, 2);
1280        assert_eq!(loaded.work_records.len(), 2);
1281        assert_eq!(loaded.work_records.get(&1).unwrap().name, "Coding");
1282        assert_eq!(loaded.work_records.get(&2).unwrap().name, "Meeting");
1283    }
1284
1285    #[test]
1286    fn test_list_dates_with_records_only_returns_non_empty_days() {
1287        let temp_dir = TempDir::new().unwrap();
1288        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1289
1290        let date1 = Date::from_calendar_date(2025, time::Month::November, 6).unwrap();
1291        let date2 = Date::from_calendar_date(2025, time::Month::November, 7).unwrap();
1292        let date3 = Date::from_calendar_date(2025, time::Month::November, 8).unwrap();
1293
1294        let mut day1 = DayData::new(date1);
1295        day1.add_record(create_test_record(1, "Coding"));
1296        storage.save(&day1).unwrap();
1297
1298        // Saving an empty day should not make it exportable.
1299        storage.save(&DayData::new(date2)).unwrap();
1300
1301        let mut day3 = DayData::new(date3);
1302        day3.add_record(create_test_record(1, "Meeting"));
1303        storage.save(&day3).unwrap();
1304
1305        let dates = storage.list_dates_with_records().unwrap();
1306        assert_eq!(dates, vec![date1, date3]);
1307    }
1308
1309    #[test]
1310    fn test_get_file_modified_time_uses_revision_token() {
1311        let temp_dir = TempDir::new().unwrap();
1312        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1313        let date = create_test_date();
1314
1315        assert!(storage.get_file_modified_time(&date).is_none());
1316
1317        let mut day_data = DayData::new(date);
1318        day_data.add_record(create_test_record(1, "Task 1"));
1319        storage.save(&day_data).unwrap();
1320        let first = storage.get_file_modified_time(&date);
1321
1322        day_data.add_record(create_test_record(2, "Task 2"));
1323        storage.save(&day_data).unwrap();
1324        let second = storage.get_file_modified_time(&date);
1325
1326        assert!(first.is_some());
1327        assert!(second.is_some());
1328        assert_ne!(first, second);
1329    }
1330
1331    #[test]
1332    fn test_save_load_and_clear_active_timer() {
1333        let temp_dir = TempDir::new().unwrap();
1334        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1335        let timer = create_test_timer();
1336
1337        storage.save_active_timer(&timer).unwrap();
1338        let loaded = storage.load_active_timer().unwrap();
1339        assert!(loaded.is_some());
1340        let loaded_timer = loaded.unwrap();
1341        assert_eq!(loaded_timer.task_name, "Test Timer");
1342        assert_eq!(loaded_timer.project.as_deref(), Some("Platform"));
1343        assert_eq!(loaded_timer.customer.as_deref(), Some("ACME"));
1344
1345        storage.clear_active_timer().unwrap();
1346        assert!(storage.load_active_timer().unwrap().is_none());
1347    }
1348
1349    #[test]
1350    fn test_storage_manager_check_and_reload_detects_external_changes() {
1351        let temp_dir = TempDir::new().unwrap();
1352        let mut manager1 = StorageManager::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1353        let mut manager2 = StorageManager::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1354        let date = create_test_date();
1355
1356        manager1.load_with_tracking(date).unwrap();
1357        manager2
1358            .add_record(date, create_test_record(1, "External Change"))
1359            .unwrap();
1360
1361        let reloaded = manager1.check_and_reload(date).unwrap();
1362        assert!(reloaded.is_some());
1363        assert_eq!(reloaded.unwrap().work_records.len(), 1);
1364    }
1365
1366    #[test]
1367    fn test_json_migration_imports_legacy_day_data_and_timer() {
1368        let temp_dir = TempDir::new().unwrap();
1369        let data_dir = temp_dir.path();
1370
1371        let date = create_test_date();
1372        let mut day_data = DayData::new(date);
1373        day_data.add_record(create_test_record(1, "Legacy Task"));
1374
1375        let day_json_path = data_dir.join("2025-11-06.json");
1376        fs::write(
1377            &day_json_path,
1378            serde_json::to_string_pretty(&day_data).unwrap(),
1379        )
1380        .unwrap();
1381
1382        let timer = create_test_timer();
1383        let timer_json_path = data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME);
1384        fs::write(
1385            &timer_json_path,
1386            serde_json::to_string_pretty(&timer).unwrap(),
1387        )
1388        .unwrap();
1389
1390        let storage = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1391
1392        let loaded_day = storage.load(&date).unwrap();
1393        assert_eq!(loaded_day.work_records.len(), 1);
1394        assert_eq!(loaded_day.work_records.get(&1).unwrap().name, "Legacy Task");
1395
1396        let loaded_timer = storage.load_active_timer().unwrap();
1397        assert!(loaded_timer.is_some());
1398        assert_eq!(loaded_timer.unwrap().task_name, "Test Timer");
1399
1400        // Legacy files should be preserved as backup.
1401        assert!(day_json_path.exists());
1402        assert!(timer_json_path.exists());
1403    }
1404
1405    #[test]
1406    fn test_json_migration_is_idempotent() {
1407        let temp_dir = TempDir::new().unwrap();
1408        let data_dir = temp_dir.path();
1409
1410        let date = create_test_date();
1411        let mut day_data = DayData::new(date);
1412        day_data.add_record(create_test_record(1, "Legacy Task"));
1413
1414        let day_json_path = data_dir.join("2025-11-06.json");
1415        fs::write(
1416            &day_json_path,
1417            serde_json::to_string_pretty(&day_data).unwrap(),
1418        )
1419        .unwrap();
1420
1421        let storage1 = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1422        let loaded1 = storage1.load(&date).unwrap();
1423        assert_eq!(loaded1.work_records.len(), 1);
1424
1425        let storage2 = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1426        let loaded2 = storage2.load(&date).unwrap();
1427        assert_eq!(loaded2.work_records.len(), 1);
1428        assert_eq!(loaded2.work_records.get(&1).unwrap().name, "Legacy Task");
1429    }
1430
1431    #[test]
1432    fn test_json_migration_fails_on_malformed_legacy_day_file() {
1433        let temp_dir = TempDir::new().unwrap();
1434        let data_dir = temp_dir.path();
1435
1436        let malformed_day_path = data_dir.join("2025-11-08.json");
1437        fs::write(&malformed_day_path, "{ invalid json").unwrap();
1438
1439        let result = Storage::new_with_dir(data_dir.to_path_buf());
1440        assert!(result.is_err());
1441        let err = result.err().expect("expected malformed migration to fail");
1442        let err_msg = format!("{err:#}");
1443        let malformed_path_str = malformed_day_path.display().to_string();
1444        assert!(
1445            err_msg.contains(&malformed_path_str),
1446            "error message '{}' did not include malformed file path '{}'",
1447            err_msg,
1448            malformed_path_str
1449        );
1450
1451        let db_path = data_dir.join(DATABASE_FILE_NAME);
1452        let conn = Connection::open(db_path).unwrap();
1453        let day_rows: i64 = conn
1454            .query_row("SELECT COUNT(*) FROM day_meta", [], |row| row.get(0))
1455            .unwrap();
1456        assert_eq!(day_rows, 0);
1457    }
1458
1459    #[test]
1460    fn test_json_migration_prefers_running_timer_when_both_timer_files_exist() {
1461        let temp_dir = TempDir::new().unwrap();
1462        let data_dir = temp_dir.path();
1463
1464        let mut running_timer = create_test_timer();
1465        running_timer.task_name = "running timer file".to_string();
1466        fs::write(
1467            data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME),
1468            serde_json::to_string_pretty(&running_timer).unwrap(),
1469        )
1470        .unwrap();
1471
1472        let mut active_timer = create_test_timer();
1473        active_timer.task_name = "active timer file".to_string();
1474        fs::write(
1475            data_dir.join(LEGACY_ACTIVE_TIMER_FILE_NAME),
1476            serde_json::to_string_pretty(&active_timer).unwrap(),
1477        )
1478        .unwrap();
1479
1480        let storage = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1481        let loaded = storage.load_active_timer().unwrap().unwrap();
1482
1483        assert_eq!(loaded.task_name, "running timer file");
1484    }
1485
1486    #[test]
1487    fn test_storage_diagnostics_reports_expected_values() {
1488        let temp_dir = TempDir::new().unwrap();
1489        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1490
1491        let date = create_test_date();
1492        let mut day_data = DayData::new(date);
1493        day_data.add_record(create_test_record(1, "Task One"));
1494        day_data.add_record(create_test_record(2, "Task Two"));
1495        storage.save(&day_data).unwrap();
1496
1497        fs::write(
1498            temp_dir.path().join("2025-11-09.json"),
1499            serde_json::to_string_pretty(&day_data).unwrap(),
1500        )
1501        .unwrap();
1502
1503        let diagnostics = storage.diagnostics().unwrap();
1504
1505        assert_eq!(diagnostics.migration_marker.as_deref(), Some("done"));
1506        assert_eq!(diagnostics.days_count, 1);
1507        assert_eq!(diagnostics.work_records_count, 2);
1508        assert!(!diagnostics.active_timer_present);
1509        assert_eq!(diagnostics.legacy_day_json_files, 1);
1510        assert_eq!(diagnostics.legacy_timer_json_files, 0);
1511    }
1512
1513    #[test]
1514    fn test_schema_migration_adds_project_and_customer_columns() {
1515        let temp_dir = TempDir::new().unwrap();
1516        let db_path = temp_dir.path().join(DATABASE_FILE_NAME);
1517
1518        {
1519            let conn = Connection::open(&db_path).unwrap();
1520            conn.execute_batch(
1521                "
1522                CREATE TABLE meta (
1523                    key TEXT PRIMARY KEY,
1524                    value TEXT NOT NULL
1525                );
1526
1527                CREATE TABLE day_meta (
1528                    date TEXT PRIMARY KEY,
1529                    last_id INTEGER NOT NULL DEFAULT 0 CHECK (last_id >= 0),
1530                    revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0)
1531                );
1532
1533                CREATE TABLE work_records (
1534                    date TEXT NOT NULL,
1535                    id INTEGER NOT NULL,
1536                    name TEXT NOT NULL,
1537                    start_minutes INTEGER NOT NULL,
1538                    end_minutes INTEGER NOT NULL,
1539                    total_minutes INTEGER NOT NULL,
1540                    description TEXT NOT NULL DEFAULT '',
1541                    PRIMARY KEY (date, id),
1542                    FOREIGN KEY (date) REFERENCES day_meta(date) ON DELETE CASCADE
1543                );
1544
1545                CREATE TABLE active_timer (
1546                    singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
1547                    id INTEGER,
1548                    task_name TEXT NOT NULL,
1549                    description TEXT,
1550                    start_time TEXT NOT NULL,
1551                    end_time TEXT,
1552                    date TEXT NOT NULL,
1553                    status TEXT NOT NULL CHECK (status IN ('running', 'paused', 'stopped')),
1554                    paused_duration_secs INTEGER NOT NULL CHECK (paused_duration_secs >= 0),
1555                    paused_at TEXT,
1556                    created_at TEXT NOT NULL,
1557                    updated_at TEXT NOT NULL,
1558                    source_record_id INTEGER,
1559                    source_record_date TEXT
1560                );
1561                ",
1562            )
1563            .unwrap();
1564        }
1565
1566        let _storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1567        let conn = Connection::open(db_path).unwrap();
1568
1569        let work_records_has_project: i64 = conn
1570            .query_row(
1571                "SELECT COUNT(*) FROM pragma_table_info('work_records') WHERE name = 'project'",
1572                [],
1573                |row| row.get(0),
1574            )
1575            .unwrap();
1576        let work_records_has_customer: i64 = conn
1577            .query_row(
1578                "SELECT COUNT(*) FROM pragma_table_info('work_records') WHERE name = 'customer'",
1579                [],
1580                |row| row.get(0),
1581            )
1582            .unwrap();
1583        let active_timer_has_project: i64 = conn
1584            .query_row(
1585                "SELECT COUNT(*) FROM pragma_table_info('active_timer') WHERE name = 'project'",
1586                [],
1587                |row| row.get(0),
1588            )
1589            .unwrap();
1590        let active_timer_has_customer: i64 = conn
1591            .query_row(
1592                "SELECT COUNT(*) FROM pragma_table_info('active_timer') WHERE name = 'customer'",
1593                [],
1594                |row| row.get(0),
1595            )
1596            .unwrap();
1597
1598        assert_eq!(work_records_has_project, 1);
1599        assert_eq!(work_records_has_customer, 1);
1600        assert_eq!(active_timer_has_project, 1);
1601        assert_eq!(active_timer_has_customer, 1);
1602    }
1603}