Skip to main content

void/app/
mod.rs

1use std::collections::HashSet;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5use crossterm::event::KeyModifiers;
6use ratatui::widgets::ListState;
7
8use crate::db::Database;
9use crate::model::{
10    AppData, EmptyQueueBehavior, EstimateCompleteBehavior, Priority, StoredSession, TimerMode,
11    TimerState,
12};
13use crate::sound;
14use crate::storage;
15use crate::timer::{Timer, TimerConfig};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FocusTab {
19    Dashboard,
20    Tasks,
21    Stats,
22    Settings,
23    Help,
24}
25
26impl FocusTab {
27    pub const fn all() -> [FocusTab; 5] {
28        [
29            FocusTab::Dashboard,
30            FocusTab::Tasks,
31            FocusTab::Stats,
32            FocusTab::Settings,
33            FocusTab::Help,
34        ]
35    }
36    pub fn label(&self) -> &'static str {
37        match self {
38            FocusTab::Dashboard => "Dashboard",
39            FocusTab::Tasks => "Tasks",
40            FocusTab::Stats => "Stats",
41            FocusTab::Settings => "Settings",
42            FocusTab::Help => "Help",
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum InputMode {
49    Normal,
50    Editing,
51}
52
53#[derive(Debug, Clone)]
54pub enum BulkAction {
55    MarkDone,
56    Delete,
57}
58
59#[derive(Debug, Clone)]
60pub enum Popup {
61    AddTask,
62    EditTask(u64),
63    ConfirmDelete(u64),
64    EmptyQueueChoice,
65    AddSubtask(u64),
66    BulkConfirm(BulkAction),
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum InputField {
71    Title,
72    Estimate,
73    Priority,
74    DueDate,
75    Tags,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum TaskFilter {
80    All,
81    Pending,
82    Done,
83    Today,
84    Archived,
85}
86
87impl TaskFilter {
88    pub fn label(&self) -> &'static str {
89        match self {
90            TaskFilter::All => "All",
91            TaskFilter::Pending => "Open",
92            TaskFilter::Done => "Done",
93            TaskFilter::Today => "Today",
94            TaskFilter::Archived => "Archive",
95        }
96    }
97
98    pub fn next(self) -> Self {
99        match self {
100            TaskFilter::All => TaskFilter::Pending,
101            TaskFilter::Pending => TaskFilter::Done,
102            TaskFilter::Done => TaskFilter::Today,
103            TaskFilter::Today => TaskFilter::Archived,
104            TaskFilter::Archived => TaskFilter::All,
105        }
106    }
107}
108
109use crate::theme::{self, ThemeCatalog};
110use crate::ui::{IconMode, IconSet};
111
112pub use theme::Theme;
113
114pub mod settings;
115pub use settings::*;
116pub mod keys;
117pub mod popups;
118pub mod task_ops;
119pub mod timer_ops;
120
121pub struct App {
122    pub db: Database,
123    pub data: AppData,
124    pub timer: Timer,
125    pub tab: FocusTab,
126    pub input_mode: InputMode,
127    pub input_buffer: String,
128    pub input_due_date: String,
129    pub input_tags: String,
130    pub input_number: u32,
131    pub input_priority: Priority,
132    pub input_field: InputField,
133    pub popup: Option<Popup>,
134    pub task_state: ListState,
135    pub settings_state: SettingsState,
136    pub status: Option<String>,
137    pub status_error: bool,
138    pub last_status_set: Instant,
139    pub should_quit: bool,
140    pub theme: Theme,
141    pub theme_catalog: ThemeCatalog,
142    pub icons: IconSet,
143    pub active_task: Option<u64>,
144    pub zen_mode: bool,
145    pub task_filter: TaskFilter,
146    pub active_tag_filter: Option<String>,
147    pub task_search: String,
148    pub searching: bool,
149    pub weekly_chart: Vec<(String, u32)>,
150    pub heatmap_data: Vec<(String, u32)>,
151    pub session_counts: (u32, u32, u32),
152    pub chart_dirty: bool,
153    pub data_version: u64,
154    pub recent_sessions: Vec<StoredSession>,
155    pub stats_session_selected: usize,
156    pub dashboard_task_selected: usize,
157    pub calendar_date: chrono::NaiveDate,
158    pub bulk_mode: bool,
159    pub bulk_selected: HashSet<u64>,
160    pub reordering_task: Option<u64>,
161    pub subtask_selected: usize,
162    pub subtask_focus: bool,
163    pub subtask_state: ListState,
164    pub stats_session_page: usize,
165    pub stats_session_total: usize,
166    pub end_warning_shown: bool,
167    pub last_activity: Instant,
168    pub timeline_sessions: Vec<StoredSession>,
169}
170
171impl App {
172    pub fn new() -> Result<Self> {
173        let db = Database::open()?;
174        let mut data = db.load_app_data().unwrap_or_default();
175        let _ = storage::ensure_today_reset(&db, &mut data);
176        let config = TimerConfig::from_app_data(&data);
177        let mut timer = Timer::new(config);
178        let (completed, mode) = db.load_timer_state();
179        timer.completed_focus_sessions = completed;
180        timer.configure(mode);
181        let recent_sessions = db.recent_sessions(15).unwrap_or_default();
182        let stats_session_total = db.session_count().unwrap_or(0);
183        let today_str = chrono::Local::now().format("%Y-%m-%d").to_string();
184        let timeline_sessions = db.sessions_on_date(&today_str).unwrap_or_default();
185        let archived = storage::auto_archive_old_tasks(&db, &mut data).unwrap_or(0);
186        let mut task_state = ListState::default();
187        if !data.tasks.is_empty() {
188            task_state.select(Some(0));
189        }
190        let weekly_chart = storage::minutes_by_date(&db, 7).unwrap_or_default();
191        let heatmap_data = storage::focus_heatmap(&db).unwrap_or_default();
192        let session_counts = db.session_counts_by_mode().unwrap_or((0, 0, 0));
193        let theme_catalog = ThemeCatalog::load();
194        let theme_id = theme::normalize_theme_id(&data.theme);
195        data.theme = theme_id.clone();
196        let theme = theme::resolve(&theme_id, &theme_catalog).unwrap_or_else(|_| Theme::matrix());
197        let icons = IconSet::detect();
198        let active_task = data.active_task_id.filter(|id| {
199            data.tasks
200                .iter()
201                .find(|t| t.id == *id)
202                .is_some_and(|t| t.status != crate::model::TaskStatus::Done)
203        });
204        if active_task != data.active_task_id {
205            data.active_task_id = active_task;
206        }
207        let welcome = match icons.mode() {
208            IconMode::Ascii => {
209                "Welcome to Void! Using text icons (set VOID_USE_NERD_FONTS=1 if your terminal has a Nerd Font)."
210            }
211            IconMode::Nerd => "Welcome to Void! Press 5 or 'h' for help.",
212        };
213        let mut status_msg = welcome.to_string();
214        if archived > 0 {
215            status_msg = format!("Auto-archived {archived} old tasks. {welcome}");
216        }
217        let (overdue, due_today) = storage::overdue_and_due_today(&data);
218        if !overdue.is_empty() || !due_today.is_empty() {
219            let mut parts = Vec::new();
220            if !overdue.is_empty() {
221                parts.push(format!("{} overdue", overdue.len()));
222            }
223            if !due_today.is_empty() {
224                parts.push(format!("{} due today", due_today.len()));
225            }
226            if data.notify_on_finish {
227                sound::notify_typed(
228                    sound::NotifyKind::FocusComplete,
229                    "Void · Task reminders",
230                    &parts.join(", "),
231                );
232            }
233            status_msg = format!("{} · {}", parts.join(", "), status_msg);
234        }
235        Ok(Self {
236            db,
237            data,
238            timer,
239            tab: FocusTab::Dashboard,
240            input_mode: InputMode::Normal,
241            input_buffer: String::new(),
242            input_due_date: String::new(),
243            input_tags: String::new(),
244            input_number: 25,
245            input_priority: Priority::Medium,
246            input_field: InputField::Title,
247            popup: None,
248            task_state,
249            settings_state: SettingsState::new(),
250            status: Some(status_msg),
251            status_error: false,
252            last_status_set: Instant::now(),
253            should_quit: false,
254            theme,
255            theme_catalog,
256            icons,
257            active_task,
258            zen_mode: false,
259            task_filter: TaskFilter::All,
260            active_tag_filter: None,
261            task_search: String::new(),
262            searching: false,
263            weekly_chart,
264            heatmap_data,
265            session_counts,
266            chart_dirty: false,
267            data_version: 0,
268            recent_sessions,
269            stats_session_selected: 0,
270            dashboard_task_selected: 0,
271            calendar_date: chrono::Local::now().date_naive(),
272            bulk_mode: false,
273            bulk_selected: HashSet::new(),
274            reordering_task: None,
275            subtask_selected: 0,
276            subtask_focus: false,
277            subtask_state: ListState::default(),
278            stats_session_page: 0,
279            stats_session_total,
280            end_warning_shown: false,
281            last_activity: Instant::now(),
282            timeline_sessions,
283        })
284    }
285
286    pub const SESSIONS_PER_PAGE: usize = 15;
287
288    pub fn apply_theme(&mut self, id: &str) {
289        let id = theme::normalize_theme_id(id);
290        match theme::resolve(&id, &self.theme_catalog) {
291            Ok(resolved) => {
292                self.theme = resolved;
293                self.data.theme = id.clone();
294                self.persist_setting("theme", &id);
295            }
296            Err(err) => {
297                self.set_status(format!("Theme `{id}` unavailable: {err:#}"), true);
298            }
299        }
300    }
301
302    pub fn queue_empty(&self) -> bool {
303        storage::queue_empty(&self.data)
304    }
305
306    pub fn daily_goal_met(&self) -> bool {
307        storage::today_focus_minutes(&self.data) >= self.data.daily_goal_minutes
308    }
309
310    fn persist_timer_state(&mut self) {
311        let completed = self.timer.completed_focus_sessions;
312        let mode = self.timer.mode;
313        self.persist(|db| db.persist_timer_state(completed, mode));
314    }
315
316    pub(crate) fn refresh_recent_sessions(&mut self) {
317        let offset = self.stats_session_page * Self::SESSIONS_PER_PAGE;
318        match self
319            .db
320            .recent_sessions_paged(offset, Self::SESSIONS_PER_PAGE)
321        {
322            Ok(sessions) => self.recent_sessions = sessions,
323            Err(e) => self.set_status(format!("Error loading sessions: {e}"), true),
324        }
325        self.stats_session_total = self.db.session_count().unwrap_or(0);
326        if self.stats_session_selected >= self.recent_sessions.len() {
327            self.stats_session_selected = self.recent_sessions.len().saturating_sub(1);
328        }
329        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
330        self.timeline_sessions = self.db.sessions_on_date(&today).unwrap_or_default();
331    }
332
333    pub fn tick_rate(&self) -> Duration {
334        match self.timer.state {
335            TimerState::Running => Duration::from_millis(50),
336            TimerState::Paused => Duration::from_millis(100),
337            TimerState::Idle => Duration::from_millis(100),
338            _ => Duration::from_millis(200),
339        }
340    }
341
342    pub fn window_title(&self) -> String {
343        if !self.data.show_terminal_title {
344            return "Void".into();
345        }
346        let (main, tenths, _) = crate::canvas_timer::format_time_stack(&self.timer);
347        let state = match self.timer.state {
348            TimerState::Running => self.icons.play,
349            TimerState::Paused => self.icons.pause,
350            TimerState::Finished => self.icons.check,
351            TimerState::Idle => self.icons.idle,
352        };
353        format!(
354            "Void {} {}{} · {}",
355            state,
356            main,
357            tenths,
358            self.timer.mode.label()
359        )
360    }
361
362    pub fn bump_data(&mut self) {
363        self.data_version = self.data_version.wrapping_add(1);
364        self.chart_dirty = true;
365        self.refresh_recent_sessions();
366        self.clamp_dashboard_task_selection();
367    }
368
369    fn persist<F>(&mut self, op: F)
370    where
371        F: FnOnce(&Database) -> anyhow::Result<()>,
372    {
373        if let Err(e) = op(&self.db) {
374            self.set_status(format!("Save error: {e}"), true);
375        }
376    }
377
378    fn persist_data<F>(&mut self, op: F)
379    where
380        F: FnOnce(&Database, &mut AppData) -> anyhow::Result<()>,
381    {
382        if let Err(e) = op(&self.db, &mut self.data) {
383            self.set_status(format!("Save error: {e}"), true);
384        }
385    }
386
387    fn persist_setting(&mut self, key: &str, value: impl AsRef<str>) {
388        self.persist(|db| db.set_setting(key, value.as_ref()));
389    }
390
391    pub fn refresh_chart_if_needed(&mut self) {
392        if self.chart_dirty {
393            match storage::minutes_by_date(&self.db, 7) {
394                Ok(data) => self.weekly_chart = data,
395                Err(e) => self.set_status(format!("Chart error: {e}"), true),
396            }
397            match storage::focus_heatmap(&self.db) {
398                Ok(data) => self.heatmap_data = data,
399                Err(e) => self.set_status(format!("Heatmap error: {e}"), true),
400            }
401            match self.db.session_counts_by_mode() {
402                Ok(counts) => self.session_counts = counts,
403                Err(e) => self.set_status(format!("Stats error: {e}"), true),
404            }
405            self.chart_dirty = false;
406        }
407    }
408
409    pub fn reload_heatmap(&mut self) {
410        if let Ok(data) = storage::focus_heatmap(&self.db) {
411            self.heatmap_data = data;
412        }
413    }
414
415    fn sync_timer_config_to_data(&mut self) {
416        self.data.focus_minutes = self.timer.config.focus_minutes;
417        self.data.short_break_minutes = self.timer.config.short_break_minutes;
418        self.data.long_break_minutes = self.timer.config.long_break_minutes;
419        self.data.long_break_every = self.timer.config.long_break_every;
420    }
421
422    fn elapsed_minutes(&self, skipped: bool) -> u32 {
423        let secs = self.timer.current_elapsed_seconds();
424        if skipped {
425            secs.div_ceil(60).max(1)
426        } else {
427            (secs / 60).max(1)
428        }
429    }
430
431    pub fn hint(&self) -> String {
432        match self.tab {
433            FocusTab::Dashboard => "[j/k]tasks [f]ocus [Enter]status [x]done [s]tart [z]zen".into(),
434            FocusTab::Tasks => {
435                "[Tab] subtasks · [c] add · [x] toggle · [q] back · [v] bulk · [A] archive".into()
436            }
437            FocusTab::Stats => {
438                "[j/k] sessions [[/]] page [d] delete [+/-] minutes [E] end session".into()
439            }
440            FocusTab::Settings => "[↑↓] nav [Enter] toggle/export [-/+] change values".into(),
441            FocusTab::Help => "Press Tab or 1-5 to leave help".into(),
442        }
443    }
444
445    pub fn set_status(&mut self, msg: impl Into<String>, error: bool) {
446        self.status = Some(msg.into());
447        self.status_error = error;
448        self.last_status_set = Instant::now();
449    }
450
451    fn check_queue_empty(&mut self) {
452        if self.queue_empty() {
453            self.on_queue_empty();
454        }
455    }
456
457    fn on_queue_empty(&mut self) {
458        match self.data.empty_queue_behavior {
459            EmptyQueueBehavior::FreeFocus => {
460                self.set_status(
461                    "All tasks done — free focus. Sessions log as general focus. [E] end session",
462                    false,
463                );
464            }
465            EmptyQueueBehavior::PauseTimer => {
466                if self.timer.state == TimerState::Running {
467                    self.pause_timer();
468                } else if self.timer.state != TimerState::Paused {
469                    self.timer.reset();
470                }
471                self.set_status("All tasks done — timer paused. [E] end session", false);
472            }
473            EmptyQueueBehavior::AskEachTime => {
474                self.popup = Some(Popup::EmptyQueueChoice);
475                self.set_status(
476                    "All tasks done — [Enter] free focus  [p] pause  [a] add task",
477                    false,
478                );
479            }
480        }
481    }
482
483    pub fn export_backup(&mut self) {
484        match self.db.export_json() {
485            Ok(path) => self.set_status(format!("Exported backup to {}", path.display()), false),
486            Err(e) => self.set_status(format!("Export failed: {e}"), true),
487        }
488    }
489
490    pub fn open_add_task(&mut self) {
491        self.input_buffer.clear();
492        self.input_due_date.clear();
493        self.input_tags.clear();
494        self.input_number = 25;
495        self.input_priority = Priority::Medium;
496        self.input_field = InputField::Title;
497        self.popup = Some(Popup::AddTask);
498        self.input_mode = InputMode::Editing;
499    }
500
501    pub fn open_edit_task(&mut self) {
502        let Some(id) = self.selected_task_id() else {
503            self.set_status("No task selected.", true);
504            return;
505        };
506        if let Some(t) = self.data.tasks.iter().find(|t| t.id == id).cloned() {
507            self.input_buffer = t.title;
508            self.input_due_date = t.due_date.unwrap_or_default();
509            self.input_tags = t.tags.join(", ");
510            self.input_number = t.estimated_minutes;
511            self.input_priority = t.priority;
512            self.input_field = InputField::Title;
513            self.popup = Some(Popup::EditTask(id));
514            self.input_mode = InputMode::Editing;
515        }
516    }
517
518    pub fn open_confirm_delete(&mut self) {
519        if let Some(id) = self.selected_task_id() {
520            self.popup = Some(Popup::ConfirmDelete(id));
521        } else {
522            self.set_status("No task to delete.", true);
523        }
524    }
525
526    fn popup_due_date(&self) -> Result<Option<String>, String> {
527        let allow_past = matches!(self.popup, Some(crate::app::Popup::EditTask(_)));
528        storage::normalize_due_date(&self.input_due_date, allow_past)
529    }
530
531    pub fn cycle_tag_filter(&mut self) {
532        let mut tags: Vec<String> = self
533            .data
534            .tasks
535            .iter()
536            .flat_map(|t| t.tags.clone())
537            .collect();
538        tags.sort();
539        tags.dedup();
540
541        if tags.is_empty() {
542            self.set_status("No tags available to filter.", true);
543            return;
544        }
545
546        self.active_tag_filter = match &self.active_tag_filter {
547            None => Some(tags[0].clone()),
548            Some(current) => {
549                if let Some(idx) = tags.iter().position(|t| t == current) {
550                    if idx + 1 < tags.len() {
551                        Some(tags[idx + 1].clone())
552                    } else {
553                        None
554                    }
555                } else {
556                    None
557                }
558            }
559        };
560
561        let msg = match &self.active_tag_filter {
562            Some(t) => format!("Filtered by tag: #{}", t),
563            None => "Tag filter cleared.".to_string(),
564        };
565        self.set_status(msg, false);
566
567        self.clamp_dashboard_task_selection();
568        let len = self.filtered_task_indices().len();
569        if len == 0 {
570            self.task_state.select(None);
571        } else {
572            let sel = self.task_state.selected().unwrap_or(0).min(len - 1);
573            self.task_state.select(Some(sel));
574        }
575    }
576
577    fn popup_tags(&self) -> Vec<String> {
578        storage::parse_tags(&self.input_tags)
579    }
580
581    pub fn selected_task_id(&self) -> Option<u64> {
582        let indices = self.filtered_task_indices();
583        self.task_state
584            .selected()
585            .and_then(|i| indices.get(i).copied())
586            .and_then(|idx| self.data.tasks.get(idx).map(|t| t.id))
587    }
588}