void-focus 0.3.0-alpha.4

A feature-rich terminal focus timer with task tracking
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
    Low,
    Medium,
    High,
}

impl Priority {
    pub fn label(&self) -> &'static str {
        match self {
            Priority::Low => "Low",
            Priority::Medium => "Med",
            Priority::High => "High",
        }
    }

    pub fn rank(&self) -> u8 {
        match self {
            Priority::High => 3,
            Priority::Medium => 2,
            Priority::Low => 1,
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
    Pending,
    InProgress,
    Done,
}

impl TaskStatus {
    pub fn label(&self) -> &'static str {
        match self {
            TaskStatus::Pending => "Pending",
            TaskStatus::InProgress => "In Progress",
            TaskStatus::Done => "Done",
        }
    }

    pub fn short_label(&self) -> &'static str {
        match self {
            TaskStatus::Pending => "Todo",
            TaskStatus::InProgress => "Active",
            TaskStatus::Done => "Done",
        }
    }

    pub fn icon(&self) -> &'static str {
        match self {
            TaskStatus::Pending => "",
            TaskStatus::InProgress => "",
            TaskStatus::Done => "",
        }
    }

    pub fn bracket_marker(&self) -> &'static str {
        match self {
            TaskStatus::Pending => "[ ]",
            TaskStatus::InProgress => "[~]",
            TaskStatus::Done => "[x]",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub notes: String,
    pub priority: Priority,
    pub status: TaskStatus,
    pub estimated_minutes: u32,
    pub created_at: DateTime<Utc>,
    pub completed_at: Option<DateTime<Utc>>,
    pub actual_minutes: u32,
    pub sessions: u32,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub due_date: Option<String>,
    #[serde(default)]
    pub today: bool,
    #[serde(default)]
    pub sort_order: u32,
}

impl Task {
    pub fn new(id: u64, title: String) -> Self {
        Self {
            id,
            title,
            notes: String::new(),
            priority: Priority::Medium,
            status: TaskStatus::Pending,
            estimated_minutes: 25,
            created_at: Utc::now(),
            completed_at: None,
            actual_minutes: 0,
            sessions: 0,
            tags: Vec::new(),
            due_date: None,
            today: false,
            sort_order: id as u32,
        }
    }

    pub fn is_overdue(&self) -> bool {
        if self.status == TaskStatus::Done {
            return false;
        }
        let Some(ref due) = self.due_date else {
            return false;
        };
        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
        due.as_str() < today.as_str()
    }

    pub fn progress_ratio(&self) -> f64 {
        if self.estimated_minutes == 0 {
            return 0.0;
        }
        (self.actual_minutes as f64 / self.estimated_minutes as f64).clamp(0.0, 1.0)
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TimerState {
    Idle,
    Running,
    Paused,
    Finished,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TimerMode {
    Focus,
    ShortBreak,
    LongBreak,
    Custom,
}

impl TimerMode {
    pub fn label(&self) -> &'static str {
        match self {
            TimerMode::Focus => "FOCUS",
            TimerMode::ShortBreak => "SHORT BREAK",
            TimerMode::LongBreak => "LONG BREAK",
            TimerMode::Custom => "CUSTOM",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocusSessionRecord {
    pub date: String,
    pub minutes: u32,
    pub task_id: Option<u64>,
    pub mode: TimerMode,
    pub completed_at: DateTime<Utc>,
}

#[derive(Debug, Clone)]
pub struct StoredSession {
    pub id: i64,
    pub record: FocusSessionRecord,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum EmptyQueueBehavior {
    #[default]
    FreeFocus,
    PauseTimer,
    AskEachTime,
}

impl EmptyQueueBehavior {
    pub fn label(&self) -> &'static str {
        match self {
            EmptyQueueBehavior::FreeFocus => "Free focus",
            EmptyQueueBehavior::PauseTimer => "Pause timer",
            EmptyQueueBehavior::AskEachTime => "Ask each time",
        }
    }

    pub fn next(self) -> Self {
        match self {
            EmptyQueueBehavior::FreeFocus => EmptyQueueBehavior::PauseTimer,
            EmptyQueueBehavior::PauseTimer => EmptyQueueBehavior::AskEachTime,
            EmptyQueueBehavior::AskEachTime => EmptyQueueBehavior::FreeFocus,
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum EstimateCompleteBehavior {
    #[default]
    Nudge,
    None,
    AutoDone,
}

impl EstimateCompleteBehavior {
    pub fn label(&self) -> &'static str {
        match self {
            EstimateCompleteBehavior::Nudge => "Nudge",
            EstimateCompleteBehavior::None => "Off",
            EstimateCompleteBehavior::AutoDone => "Auto-done",
        }
    }

    pub fn next(self) -> Self {
        match self {
            EstimateCompleteBehavior::Nudge => EstimateCompleteBehavior::None,
            EstimateCompleteBehavior::None => EstimateCompleteBehavior::AutoDone,
            EstimateCompleteBehavior::AutoDone => EstimateCompleteBehavior::Nudge,
        }
    }
}

fn default_focus_minutes() -> u32 {
    25
}

fn default_short_break() -> u32 {
    5
}

fn default_long_break() -> u32 {
    15
}

fn default_long_every() -> u32 {
    4
}

fn default_true() -> bool {
    true
}

fn default_theme_id() -> String {
    "matrix".into()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppData {
    pub tasks: Vec<Task>,
    pub total_focus_minutes: u32,
    pub total_sessions: u32,
    pub streak_days: u32,
    pub last_session_date: Option<String>,
    pub daily_goal_minutes: u32,
    pub sound_enabled: bool,
    pub auto_start_breaks: bool,
    pub auto_start_focus: bool,
    pub next_id: u64,
    #[serde(default)]
    pub today_focus_minutes: u32,
    #[serde(default)]
    pub today_date: Option<String>,
    #[serde(default = "default_focus_minutes")]
    pub focus_minutes: u32,
    #[serde(default = "default_short_break")]
    pub short_break_minutes: u32,
    #[serde(default = "default_long_break")]
    pub long_break_minutes: u32,
    #[serde(default = "default_long_every")]
    pub long_break_every: u32,
    #[serde(default)]
    pub session_history: Vec<FocusSessionRecord>,
    #[serde(default = "default_true")]
    pub auto_pick_task: bool,
    #[serde(default)]
    pub auto_advance_task: bool,
    #[serde(default = "default_theme_id")]
    pub theme: String,
    #[serde(default)]
    pub active_task_id: Option<u64>,
    #[serde(default = "default_true")]
    pub notify_on_finish: bool,
    #[serde(default)]
    pub goal_streak_days: u32,
    #[serde(default)]
    pub last_goal_date: Option<String>,
    #[serde(default)]
    pub empty_queue_behavior: EmptyQueueBehavior,
    #[serde(default)]
    pub log_breaks: bool,
    #[serde(default)]
    pub estimate_complete: EstimateCompleteBehavior,
}

impl Default for AppData {
    fn default() -> Self {
        Self {
            tasks: Vec::new(),
            total_focus_minutes: 0,
            total_sessions: 0,
            streak_days: 0,
            last_session_date: None,
            daily_goal_minutes: 120,
            sound_enabled: true,
            auto_start_breaks: false,
            auto_start_focus: false,
            next_id: 1,
            today_focus_minutes: 0,
            today_date: None,
            focus_minutes: 25,
            short_break_minutes: 5,
            long_break_minutes: 15,
            long_break_every: 4,
            session_history: Vec::new(),
            auto_pick_task: true,
            auto_advance_task: false,
            theme: default_theme_id(),
            active_task_id: None,
            notify_on_finish: true,
            goal_streak_days: 0,
            last_goal_date: None,
            empty_queue_behavior: EmptyQueueBehavior::FreeFocus,
            log_breaks: false,
            estimate_complete: EstimateCompleteBehavior::Nudge,
        }
    }
}