Skip to main content

work_tuimer/timer/
mod.rs

1//! Timer module for automatic time tracking
2//!
3//! This module provides automatic timer functionality for tracking work sessions.
4//! Timers can be started, paused, resumed, and stopped, with automatic conversion
5//! to WorkRecord upon completion.
6
7use crate::models::{TimePoint, WorkRecord};
8use crate::storage::Storage;
9use anyhow::{Context, Result, anyhow};
10use serde::{Deserialize, Serialize};
11use std::time::Duration as StdDuration;
12use time::{Date, OffsetDateTime};
13
14/// Timer status enumeration
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum TimerStatus {
18    Running,
19    Paused,
20    Stopped,
21}
22
23/// Active timer state with SQLite-ready fields
24///
25/// This struct represents an active timer session. All fields are designed
26/// to be compatible with SQLite storage for future migration (Issue #22).
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct TimerState {
29    /// Optional ID for future SQLite primary key (currently unused)
30    pub id: Option<u32>,
31
32    /// Task name being tracked
33    pub task_name: String,
34
35    /// Optional description for the task
36    pub description: Option<String>,
37
38    /// Optional project name for grouping/reporting
39    #[serde(default)]
40    pub project: Option<String>,
41
42    /// Optional customer name for client-related work
43    #[serde(default)]
44    pub customer: Option<String>,
45
46    /// When the timer was started (UTC)
47    pub start_time: OffsetDateTime,
48
49    /// When the timer was stopped (UTC), None if still active
50    pub end_time: Option<OffsetDateTime>,
51
52    /// Date when timer was started
53    pub date: Date,
54
55    /// Current status of the timer
56    pub status: TimerStatus,
57
58    /// Total duration in seconds when paused (cumulative)
59    pub paused_duration_secs: i64,
60
61    /// When timer was last paused (to track current pause duration)
62    pub paused_at: Option<OffsetDateTime>,
63
64    /// When this timer record was created (audit field)
65    pub created_at: OffsetDateTime,
66
67    /// When this timer record was last updated (audit field)
68    pub updated_at: OffsetDateTime,
69
70    /// ID of the source work record that this timer was started from
71    /// If present, stopping the timer will update the existing record instead of creating a new one
72    #[serde(default)]
73    pub source_record_id: Option<u32>,
74
75    /// Date of the source work record (needed when timer is started from a past/future date view)
76    /// If present, we'll update the record in this date's file instead of the timer start date
77    #[serde(default)]
78    pub source_record_date: Option<Date>,
79}
80
81/// Timer manager for controlling timer operations
82///
83/// Provides methods to start, stop, pause, and resume timers, as well as
84/// query their current status. Manages persistence through the StorageManager layer.
85pub struct TimerManager {
86    storage: Storage,
87}
88
89impl TimerManager {
90    /// Create a new timer manager with low-level Storage
91    /// For internal use - external callers should use storage::StorageManager instead
92    pub fn new(storage: Storage) -> Self {
93        TimerManager { storage }
94    }
95
96    /// Start a new timer
97    ///
98    /// # Errors
99    /// Returns an error if a timer is already running
100    pub fn start(
101        &self,
102        task_name: String,
103        description: Option<String>,
104        project: Option<String>,
105        customer: Option<String>,
106        source_record_id: Option<u32>,
107        source_record_date: Option<Date>,
108    ) -> Result<TimerState> {
109        // Check if timer already running
110        if (self.storage.load_active_timer()?).is_some() {
111            return Err(anyhow!("A timer is already running"));
112        }
113
114        let now = OffsetDateTime::now_local()
115            .context("Failed to get local time. System clock may not be configured correctly.")?;
116        let timer = TimerState {
117            id: None,
118            task_name,
119            description,
120            project,
121            customer,
122            start_time: now,
123            end_time: None,
124            date: now.date(),
125            status: TimerStatus::Running,
126            paused_duration_secs: 0,
127            paused_at: None,
128            created_at: now,
129            updated_at: now,
130            source_record_id,
131            source_record_date,
132        };
133
134        self.storage.save_active_timer(&timer)?;
135        Ok(timer)
136    }
137
138    /// Stop the active timer and convert it to a WorkRecord
139    ///
140    /// # Errors
141    /// Returns an error if no timer is running
142    pub fn stop(&self) -> Result<WorkRecord> {
143        let mut timer = self
144            .storage
145            .load_active_timer()?
146            .ok_or_else(|| anyhow!("No timer is currently running"))?;
147
148        let now = OffsetDateTime::now_local()
149            .context("Failed to get local time. System clock may not be configured correctly.")?;
150
151        // Determine which date's data file to load:
152        // - If timer has source_record_date, use that (record is from a specific day's view)
153        // - Otherwise use timer.start_time.date() (creating new record on timer's start date)
154        let target_date = timer
155            .source_record_date
156            .unwrap_or_else(|| timer.start_time.date());
157
158        timer.end_time = Some(now);
159        timer.status = TimerStatus::Stopped;
160        timer.updated_at = now;
161
162        // Load the day's data file
163        let mut day_data = self.storage.load(&target_date)?;
164
165        // If timer was started from an existing record, update that record's end time
166        // Otherwise, create a new work record
167        if let Some(source_id) = timer.source_record_id {
168            // Find and update the existing record
169            if let Some(record) = day_data.work_records.get_mut(&source_id) {
170                // Update the end time to now
171                let end_timepoint = TimePoint::new(now.hour(), now.minute())
172                    .map_err(|e| anyhow!(e))
173                    .context("Failed to create TimePoint for timer end time")?;
174                record.end = end_timepoint;
175                record.update_duration();
176            } else {
177                // Source record not found, create new one instead
178                let mut work_record = self.to_work_record(timer.clone())?;
179                // Assign proper ID from day_data instead of using placeholder
180                work_record.id = day_data.next_id();
181                day_data.add_record(work_record);
182            }
183        } else {
184            // No source record, create a new work record
185            let mut work_record = self.to_work_record(timer.clone())?;
186            // Assign proper ID from day_data instead of using placeholder
187            work_record.id = day_data.next_id();
188            day_data.add_record(work_record);
189        }
190
191        self.storage.save(&day_data)?;
192        self.storage.clear_active_timer()?;
193
194        // Return a work record for the stopped timer (for display purposes)
195        let work_record = self.to_work_record(timer)?;
196        Ok(work_record)
197    }
198
199    /// Pause the active timer
200    ///
201    /// # Errors
202    /// Returns an error if timer is not running
203    pub fn pause(&self) -> Result<TimerState> {
204        let mut timer = self
205            .storage
206            .load_active_timer()?
207            .ok_or_else(|| anyhow!("No timer is currently running"))?;
208
209        if timer.status == TimerStatus::Paused {
210            return Err(anyhow!("Timer is already paused"));
211        }
212
213        if timer.status != TimerStatus::Running {
214            return Err(anyhow!("Can only pause a running timer"));
215        }
216
217        let now = OffsetDateTime::now_local()
218            .context("Failed to get local time. System clock may not be configured correctly.")?;
219        timer.paused_at = Some(now);
220        timer.status = TimerStatus::Paused;
221        timer.updated_at = now;
222
223        self.storage.save_active_timer(&timer)?;
224        Ok(timer)
225    }
226
227    /// Resume a paused timer
228    ///
229    /// # Errors
230    /// Returns an error if timer is not paused
231    pub fn resume(&self) -> Result<TimerState> {
232        let mut timer = self
233            .storage
234            .load_active_timer()?
235            .ok_or_else(|| anyhow!("No timer is currently running"))?;
236
237        if timer.status != TimerStatus::Paused {
238            return Err(anyhow!("Can only resume a paused timer"));
239        }
240
241        let now = OffsetDateTime::now_local()
242            .context("Failed to get local time. System clock may not be configured correctly.")?;
243
244        // Add current pause duration to cumulative paused time
245        if let Some(paused_at) = timer.paused_at {
246            let pause_duration = (now - paused_at).whole_seconds();
247            timer.paused_duration_secs += pause_duration;
248        }
249
250        timer.paused_at = None;
251        timer.status = TimerStatus::Running;
252        timer.updated_at = now;
253
254        self.storage.save_active_timer(&timer)?;
255        Ok(timer)
256    }
257
258    /// Get the current timer status
259    ///
260    /// Returns None if no timer is running
261    pub fn status(&self) -> Result<Option<TimerState>> {
262        self.storage.load_active_timer()
263    }
264
265    /// Calculate elapsed duration of a timer
266    ///
267    /// Returns the time since start_time, minus any paused durations.
268    pub fn get_elapsed_duration(&self, timer: &TimerState) -> StdDuration {
269        let end_point = if timer.status == TimerStatus::Paused {
270            // If paused, use when it was paused
271            timer.paused_at.unwrap_or_else(|| {
272                OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
273            })
274        } else {
275            // If running, use now
276            OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
277        };
278
279        let elapsed = end_point - timer.start_time;
280        let paused_duration_std = StdDuration::from_secs(timer.paused_duration_secs as u64);
281
282        // Convert time::Duration to std::Duration for arithmetic
283        let elapsed_std = StdDuration::from_secs(elapsed.whole_seconds() as u64)
284            + StdDuration::from_nanos(elapsed.subsec_nanoseconds() as u64);
285
286        elapsed_std
287            .checked_sub(paused_duration_std)
288            .unwrap_or(StdDuration::ZERO)
289    }
290
291    /// Convert a stopped timer to a WorkRecord
292    fn to_work_record(&self, timer: TimerState) -> Result<WorkRecord> {
293        if timer.status != TimerStatus::Stopped {
294            return Err(anyhow!("Can only convert stopped timers to WorkRecord"));
295        }
296
297        let start_time = timer.start_time;
298        let end_time = timer
299            .end_time
300            .ok_or_else(|| anyhow!("Stopped timer must have end_time"))?;
301
302        // Extract just the time portion from the OffsetDateTime values
303        let start_timepoint = TimePoint::new(start_time.hour(), start_time.minute())
304            .map_err(|e| anyhow!(e))
305            .context("Failed to create TimePoint for timer start time")?;
306
307        let end_timepoint = TimePoint::new(end_time.hour(), end_time.minute())
308            .map_err(|e| anyhow!(e))
309            .context("Failed to create TimePoint for timer end time")?;
310
311        let mut record = WorkRecord::new(
312            1, // Placeholder ID, will be set by DayData
313            timer.task_name,
314            start_timepoint,
315            end_timepoint,
316        );
317
318        if let Some(description) = timer.description {
319            record.description = description;
320        }
321
322        if let Some(project) = timer.project {
323            record.project = project;
324        }
325
326        if let Some(customer) = timer.customer {
327            record.customer = customer;
328        }
329
330        Ok(record)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use tempfile::TempDir;
338
339    fn create_test_storage() -> (Storage, TempDir) {
340        let temp_dir = TempDir::new().unwrap();
341        let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
342        (storage, temp_dir)
343    }
344
345    #[test]
346    fn test_timer_state_creation() {
347        let now = OffsetDateTime::now_utc();
348        let timer = TimerState {
349            id: None,
350            task_name: "Test Task".to_string(),
351            description: None,
352            project: None,
353            customer: None,
354            start_time: now,
355            end_time: None,
356            date: now.date(),
357            status: TimerStatus::Running,
358            paused_duration_secs: 0,
359            paused_at: None,
360            created_at: now,
361            updated_at: now,
362            source_record_id: None,
363            source_record_date: None,
364        };
365
366        assert_eq!(timer.task_name, "Test Task");
367        assert_eq!(timer.status, TimerStatus::Running);
368        assert_eq!(timer.paused_duration_secs, 0);
369    }
370
371    #[test]
372    fn test_timer_serialization() {
373        let now = OffsetDateTime::now_utc();
374        let timer = TimerState {
375            id: None,
376            task_name: "Test Task".to_string(),
377            description: Some("Test description".to_string()),
378            project: Some("Platform".to_string()),
379            customer: Some("ACME".to_string()),
380            start_time: now,
381            end_time: None,
382            date: now.date(),
383            status: TimerStatus::Running,
384            paused_duration_secs: 0,
385            paused_at: None,
386            created_at: now,
387            updated_at: now,
388            source_record_id: None,
389            source_record_date: None,
390        };
391
392        let json = serde_json::to_string(&timer).unwrap();
393        let deserialized: TimerState = serde_json::from_str(&json).unwrap();
394
395        assert_eq!(deserialized.task_name, timer.task_name);
396        assert_eq!(deserialized.status, timer.status);
397    }
398
399    #[test]
400    fn test_start_timer() {
401        let (storage, _temp) = create_test_storage();
402        let manager = TimerManager::new(storage);
403
404        let result = manager.start("Work".to_string(), None, None, None, None, None);
405        assert!(result.is_ok());
406
407        let timer = result.unwrap();
408        assert_eq!(timer.task_name, "Work");
409        assert_eq!(timer.status, TimerStatus::Running);
410        assert_eq!(timer.paused_duration_secs, 0);
411    }
412
413    #[test]
414    fn test_cannot_start_when_already_running() {
415        let (storage, _temp) = create_test_storage();
416        let manager = TimerManager::new(storage);
417
418        let _ = manager.start("Task 1".to_string(), None, None, None, None, None);
419        let result = manager.start("Task 2".to_string(), None, None, None, None, None);
420
421        assert!(result.is_err());
422        assert_eq!(
423            result.unwrap_err().to_string(),
424            "A timer is already running"
425        );
426    }
427
428    #[test]
429    fn test_pause_running_timer() {
430        let (storage, _temp) = create_test_storage();
431        let manager = TimerManager::new(storage);
432
433        let _ = manager.start("Work".to_string(), None, None, None, None, None);
434        let result = manager.pause();
435
436        assert!(result.is_ok());
437        let timer = result.unwrap();
438        assert_eq!(timer.status, TimerStatus::Paused);
439        assert!(timer.paused_at.is_some());
440    }
441
442    #[test]
443    fn test_cannot_pause_paused_timer() {
444        let (storage, _temp) = create_test_storage();
445        let manager = TimerManager::new(storage);
446
447        let _ = manager.start("Work".to_string(), None, None, None, None, None);
448        let _ = manager.pause();
449        let result = manager.pause();
450
451        assert!(result.is_err());
452    }
453
454    #[test]
455    fn test_pause_without_running_timer() {
456        let (storage, _temp) = create_test_storage();
457        let manager = TimerManager::new(storage);
458
459        let result = manager.pause();
460        assert!(result.is_err());
461    }
462
463    #[test]
464    fn test_resume_paused_timer() {
465        let (storage, _temp) = create_test_storage();
466        let manager = TimerManager::new(storage);
467
468        let _ = manager.start("Work".to_string(), None, None, None, None, None);
469        let _ = manager.pause();
470        let result = manager.resume();
471
472        assert!(result.is_ok());
473        let timer = result.unwrap();
474        assert_eq!(timer.status, TimerStatus::Running);
475        assert!(timer.paused_at.is_none());
476    }
477
478    #[test]
479    fn test_resume_updates_paused_duration() {
480        let (storage, _temp) = create_test_storage();
481        let manager = TimerManager::new(storage);
482
483        let _ = manager.start("Work".to_string(), None, None, None, None, None);
484        let paused1 = manager.pause().unwrap();
485        assert_eq!(paused1.paused_duration_secs, 0);
486
487        // Simulate time passing by manually updating
488        let _ = manager.resume();
489        let paused2 = manager.pause().unwrap();
490
491        // paused_duration_secs should have increased
492        assert!(paused2.paused_duration_secs >= 0);
493    }
494
495    #[test]
496    fn test_cannot_resume_running_timer() {
497        let (storage, _temp) = create_test_storage();
498        let manager = TimerManager::new(storage);
499
500        let _ = manager.start("Work".to_string(), None, None, None, None, None);
501        let result = manager.resume();
502
503        assert!(result.is_err());
504    }
505
506    #[test]
507    fn test_status_returns_none_when_no_timer() {
508        let (storage, _temp) = create_test_storage();
509        let manager = TimerManager::new(storage);
510
511        let result = manager.status().unwrap();
512        assert!(result.is_none());
513    }
514
515    #[test]
516    fn test_status_returns_running_timer() {
517        let (storage, _temp) = create_test_storage();
518        let manager = TimerManager::new(storage);
519
520        let _ = manager.start("Work".to_string(), None, None, None, None, None);
521        let result = manager.status().unwrap();
522
523        assert!(result.is_some());
524        let timer = result.unwrap();
525        assert_eq!(timer.task_name, "Work");
526        assert_eq!(timer.status, TimerStatus::Running);
527    }
528
529    #[test]
530    fn test_stop_running_timer() {
531        let (storage, _temp) = create_test_storage();
532        let manager = TimerManager::new(storage);
533
534        let _ = manager.start("Work".to_string(), None, None, None, None, None);
535        let result = manager.stop();
536
537        assert!(result.is_ok());
538        let work_record = result.unwrap();
539        assert_eq!(work_record.name, "Work");
540
541        // Timer should be cleared
542        let timer_status = manager.status().unwrap();
543        assert!(timer_status.is_none());
544    }
545
546    #[test]
547    fn test_cannot_stop_without_running_timer() {
548        let (storage, _temp) = create_test_storage();
549        let manager = TimerManager::new(storage);
550
551        let result = manager.stop();
552        assert!(result.is_err());
553    }
554
555    #[test]
556    fn test_stop_returns_work_record_with_description() {
557        let (storage, _temp) = create_test_storage();
558        let manager = TimerManager::new(storage);
559
560        let _ = manager.start(
561            "Work".to_string(),
562            Some("Important task".to_string()),
563            None,
564            None,
565            None,
566            None,
567        );
568        let work_record = manager.stop().unwrap();
569
570        assert_eq!(work_record.name, "Work");
571        assert_eq!(work_record.description, "Important task");
572    }
573
574    #[test]
575    fn test_full_timer_lifecycle() {
576        let (storage, _temp) = create_test_storage();
577        let manager = TimerManager::new(storage);
578
579        // Start
580        let started = manager
581            .start("Task".to_string(), None, None, None, None, None)
582            .unwrap();
583        assert_eq!(started.status, TimerStatus::Running);
584
585        // Pause
586        let paused = manager.pause().unwrap();
587        assert_eq!(paused.status, TimerStatus::Paused);
588
589        // Resume
590        let resumed = manager.resume().unwrap();
591        assert_eq!(resumed.status, TimerStatus::Running);
592
593        // Pause again
594        let paused_again = manager.pause().unwrap();
595        assert_eq!(paused_again.status, TimerStatus::Paused);
596
597        // Resume again
598        let resumed_again = manager.resume().unwrap();
599        assert_eq!(resumed_again.status, TimerStatus::Running);
600
601        // Stop
602        let work_record = manager.stop().unwrap();
603        assert_eq!(work_record.name, "Task");
604
605        // Verify timer is cleared
606        let status = manager.status().unwrap();
607        assert!(status.is_none());
608    }
609
610    #[test]
611    fn test_get_elapsed_duration_running() {
612        let (storage, _temp) = create_test_storage();
613        let manager = TimerManager::new(storage);
614
615        let timer = manager
616            .start("Task".to_string(), None, None, None, None, None)
617            .unwrap();
618        let elapsed = manager.get_elapsed_duration(&timer);
619
620        // Should be close to 0 since just started
621        assert!(elapsed.as_secs() < 2);
622    }
623
624    #[test]
625    fn test_get_elapsed_duration_with_pause() {
626        let (storage, _temp) = create_test_storage();
627        let manager = TimerManager::new(storage);
628
629        let _ = manager.start("Task".to_string(), None, None, None, None, None);
630        let _ = manager.pause();
631
632        let timer = manager.status().unwrap().unwrap();
633        let elapsed = manager.get_elapsed_duration(&timer);
634
635        // Should be very small since just paused
636        assert!(elapsed.as_secs() < 2);
637    }
638
639    #[test]
640    fn test_stop_updates_existing_record() {
641        use crate::models::DayData;
642        use crate::models::TimePoint;
643        use crate::models::WorkRecord;
644        use tempfile::TempDir;
645        use time::OffsetDateTime;
646
647        // Create a temp dir and storage that we can reuse
648        let temp_dir = TempDir::new().unwrap();
649        let storage_path = temp_dir.path().to_path_buf();
650
651        // Create initial day data with one record
652        let now = OffsetDateTime::now_utc();
653        let today = now.date();
654        let mut day_data = DayData::new(today);
655
656        let record = WorkRecord::new(
657            1,
658            "Existing Task".to_string(),
659            TimePoint::new(9, 0).unwrap(),
660            TimePoint::new(10, 0).unwrap(),
661        );
662        day_data.add_record(record);
663
664        // Save using first storage instance
665        let storage1 = Storage::new_with_dir(storage_path.clone()).unwrap();
666        storage1.save(&day_data).unwrap();
667
668        // Start timer with source_record_id = 1, source_record_date = today
669        let manager = TimerManager::new(storage1);
670        manager
671            .start(
672                "Existing Task".to_string(),
673                None,
674                None,
675                None,
676                Some(1),
677                Some(today),
678            )
679            .unwrap();
680
681        // Stop timer - should update the existing record's end time
682        manager.stop().unwrap();
683
684        // Create a new storage instance pointing to the same temp dir to verify the update
685        let storage2 = Storage::new_with_dir(storage_path).unwrap();
686        let updated_day_data = storage2.load(&today).unwrap();
687
688        // Should still have only 1 record (not 2!)
689        assert_eq!(updated_day_data.work_records.len(), 1);
690
691        // The record should have updated end time (not still 10:00)
692        let updated_record = updated_day_data.work_records.get(&1).unwrap();
693        assert_eq!(updated_record.name, "Existing Task");
694        // End time should have been updated from the original 10:00
695        // We verify it changed, rather than checking against wall clock time
696        // (which is flaky due to timezone differences and midnight edge cases)
697        assert!(
698            updated_record.end.hour != 10 || updated_record.end.minute != 0,
699            "End time should have been updated from original 10:00, got {:02}:{:02}",
700            updated_record.end.hour,
701            updated_record.end.minute
702        );
703    }
704
705    #[test]
706    fn test_stop_returns_work_record_with_project_and_customer() {
707        let (storage, _temp) = create_test_storage();
708        let manager = TimerManager::new(storage);
709
710        let _ = manager.start(
711            "Work".to_string(),
712            None,
713            Some("Platform Revamp".to_string()),
714            Some("ACME".to_string()),
715            None,
716            None,
717        );
718        let work_record = manager.stop().unwrap();
719
720        assert_eq!(work_record.name, "Work");
721        assert_eq!(work_record.project, "Platform Revamp");
722        assert_eq!(work_record.customer, "ACME");
723    }
724}