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}