Skip to main content

void/
storage.rs

1use anyhow::Result;
2use chrono::Utc;
3
4use crate::db::Database;
5use crate::model::{AppData, FocusSessionRecord, Priority, Task, TaskStatus, TimerMode};
6
7pub fn next_id(db: &Database, data: &mut AppData) -> Result<u64> {
8    let id = data.next_id;
9    data.next_id = data.next_id.saturating_add(1);
10    db.set_setting("next_id", data.next_id.to_string())?;
11    Ok(id)
12}
13
14pub fn ensure_today_reset(db: &Database, data: &mut AppData) -> Result<()> {
15    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
16    if data.today_date.as_deref() != Some(today.as_str()) {
17        data.today_focus_minutes = 0;
18        data.today_date = Some(today.clone());
19        db.set_setting("today_focus_minutes", "0")?;
20        db.set_setting("today_date", &today)?;
21    }
22    Ok(())
23}
24
25pub fn parse_tags(input: &str) -> Vec<String> {
26    input
27        .split(',')
28        .map(|s| s.trim().to_string())
29        .filter(|s| !s.is_empty())
30        .collect()
31}
32
33pub fn normalize_due_date(input: &str, allow_past: bool) -> Result<Option<String>, String> {
34    let s = input.trim();
35    if s.is_empty() {
36        return Ok(None);
37    }
38    match chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
39        Ok(parsed) => {
40            if !allow_past {
41                let today = chrono::Local::now().date_naive();
42                if parsed < today {
43                    return Err("Due date cannot be in the past.".into());
44                }
45            }
46            Ok(Some(s.to_string()))
47        }
48        Err(_) => match s.to_lowercase().as_str() {
49            "today" => Ok(Some(chrono::Local::now().format("%Y-%m-%d").to_string())),
50            "tomorrow" => Ok(Some(
51                (chrono::Local::now() + chrono::Duration::days(1))
52                    .format("%Y-%m-%d")
53                    .to_string(),
54            )),
55            _ => Err("Due date must be YYYY-MM-DD, 'today', or 'tomorrow'.".into()),
56        },
57    }
58}
59
60pub struct TaskPayload {
61    pub title: String,
62    pub notes: String,
63    pub estimated_minutes: u32,
64    pub priority: Priority,
65    pub tags: Vec<String>,
66    pub due_date: Option<String>,
67}
68
69pub fn add_task_full(db: &Database, data: &mut AppData, payload: TaskPayload) -> Result<u64> {
70    let id = next_id(db, data)?;
71    let mut task = Task::new(id, payload.title);
72    task.notes = payload.notes;
73    task.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
74    task.priority = payload.priority;
75    task.tags = payload.tags;
76    task.due_date = payload.due_date;
77    db.upsert_task(&task)?;
78    data.tasks.push(task);
79    Ok(id)
80}
81
82pub fn update_task(db: &Database, data: &mut AppData, id: u64, payload: TaskPayload) -> Result<()> {
83    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
84        t.title = payload.title;
85        t.notes = payload.notes;
86        t.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
87        t.priority = payload.priority;
88        t.tags = payload.tags;
89        t.due_date = payload.due_date;
90        db.upsert_task(t)?;
91    }
92    Ok(())
93}
94
95pub fn delete_task(db: &Database, data: &mut AppData, id: u64) -> Result<bool> {
96    let before = data.tasks.len();
97    data.tasks.retain(|t| t.id != id);
98    if before == data.tasks.len() {
99        return Ok(false);
100    }
101    db.delete_task(id)?;
102    if data.active_task_id == Some(id) {
103        data.active_task_id = None;
104        db.persist_active_task(None)?;
105    }
106    Ok(true)
107}
108
109pub fn promote_task_on_activate(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
110    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
111        if t.status == TaskStatus::Pending {
112            t.status = TaskStatus::InProgress;
113            db.upsert_task(t)?;
114        }
115    }
116    Ok(())
117}
118
119pub fn mark_task_done(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
120    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
121        t.status = TaskStatus::Done;
122        t.completed_at = Some(Utc::now());
123        db.upsert_task(t)?;
124    }
125    Ok(())
126}
127
128pub fn cycle_task_status(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
129    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
130        match t.status {
131            TaskStatus::Pending => t.status = TaskStatus::InProgress,
132            TaskStatus::InProgress => {
133                t.status = TaskStatus::Done;
134                t.completed_at = Some(Utc::now());
135            }
136            TaskStatus::Done => {
137                t.status = TaskStatus::Pending;
138                t.completed_at = None;
139            }
140        }
141        db.upsert_task(t)?;
142    }
143    Ok(())
144}
145
146pub fn toggle_today(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
147    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
148        t.today = !t.today;
149        db.upsert_task(t)?;
150    }
151    Ok(())
152}
153
154pub fn set_priority(db: &Database, data: &mut AppData, id: u64, priority: Priority) -> Result<()> {
155    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
156        t.priority = priority;
157        db.upsert_task(t)?;
158    }
159    Ok(())
160}
161
162pub fn move_task(db: &Database, data: &mut AppData, id: u64, delta: i32) -> Result<()> {
163    let idx = match data.tasks.iter().position(|t| t.id == id) {
164        Some(i) => i,
165        None => return Ok(()),
166    };
167    let new_idx = (idx as i32 + delta).clamp(0, data.tasks.len() as i32 - 1) as usize;
168    if idx != new_idx {
169        let task = data.tasks.remove(idx);
170        data.tasks.insert(new_idx, task);
171        for (i, t) in data.tasks.iter_mut().enumerate() {
172            t.sort_order = i as u32;
173        }
174        db.sync_sort_orders(&data.tasks)?;
175    }
176    Ok(())
177}
178
179pub fn pick_best_task(data: &AppData) -> Option<u64> {
180    data.tasks
181        .iter()
182        .filter(|t| t.status != TaskStatus::Done)
183        .max_by(|a, b| {
184            a.priority
185                .rank()
186                .cmp(&b.priority.rank())
187                .then(b.today.cmp(&a.today))
188                .then(a.sort_order.cmp(&b.sort_order))
189        })
190        .map(|t| t.id)
191}
192
193pub fn advance_to_next_task(data: &AppData, current: Option<u64>) -> Option<u64> {
194    let pending: Vec<&Task> = data
195        .tasks
196        .iter()
197        .filter(|t| t.status != TaskStatus::Done)
198        .collect();
199    if pending.is_empty() {
200        return None;
201    }
202    if let Some(cur) = current {
203        if let Some(pos) = pending.iter().position(|t| t.id == cur) {
204            let next = (pos + 1) % pending.len();
205            return Some(pending[next].id);
206        }
207    }
208    pick_best_task(data)
209}
210
211pub fn record_focus_session(
212    db: &Database,
213    data: &mut AppData,
214    minutes: u32,
215    task_id: Option<u64>,
216    mode: TimerMode,
217) -> Result<()> {
218    ensure_today_reset(db, data)?;
219    let mins = minutes.max(1);
220    data.total_focus_minutes = data.total_focus_minutes.saturating_add(mins);
221    data.today_focus_minutes = data.today_focus_minutes.saturating_add(mins);
222    data.total_sessions = data.total_sessions.saturating_add(1);
223
224    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
225    match &data.last_session_date {
226        Some(last) if last == &today => {}
227        Some(last) => {
228            let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
229            let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
230            if let (Some(l), Some(t)) = (last_date, today_date) {
231                if t.succ_opt() == Some(l) {
232                    data.streak_days = data.streak_days.saturating_add(1);
233                } else if t != l {
234                    data.streak_days = 1;
235                }
236            } else {
237                data.streak_days = 1;
238            }
239        }
240        None => data.streak_days = 1,
241    }
242    data.last_session_date = Some(today.clone());
243    data.today_date = Some(today.clone());
244
245    let record = FocusSessionRecord {
246        date: today,
247        minutes: mins,
248        task_id,
249        mode,
250        completed_at: Utc::now(),
251    };
252    db.insert_focus_session(&record)?;
253    update_goal_streak(data)?;
254    db.persist_session_stats(data)?;
255
256    if let Some(id) = task_id {
257        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
258            t.actual_minutes = t.actual_minutes.saturating_add(mins);
259            t.sessions = t.sessions.saturating_add(1);
260            if t.status == TaskStatus::Pending {
261                t.status = TaskStatus::InProgress;
262            }
263            db.upsert_task(t)?;
264        }
265    }
266    Ok(())
267}
268
269pub fn today_focus_minutes(data: &AppData) -> u32 {
270    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
271    if data.today_date.as_deref() == Some(today.as_str()) {
272        data.today_focus_minutes
273    } else {
274        0
275    }
276}
277
278pub fn minutes_by_date(db: &Database, days: usize) -> Result<Vec<(String, u32)>> {
279    db.minutes_by_date(days)
280}
281
282pub fn focus_heatmap(db: &Database) -> Result<Vec<(String, u32)>> {
283    db.focus_minutes_grouped()
284}
285
286pub fn pending_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
287    data.tasks.iter().filter(|t| t.status != TaskStatus::Done)
288}
289
290pub fn sorted_pending_tasks(data: &AppData) -> Vec<&Task> {
291    let mut tasks: Vec<&Task> = pending_tasks(data).collect();
292    tasks.sort_by(|a, b| {
293        b.priority
294            .rank()
295            .cmp(&a.priority.rank())
296            .then(b.today.cmp(&a.today))
297            .then(a.sort_order.cmp(&b.sort_order))
298    });
299    tasks
300}
301
302pub fn completed_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
303    data.tasks.iter().filter(|t| t.status == TaskStatus::Done)
304}
305
306pub fn most_productive_hour_label(data: &AppData) -> String {
307    if data.session_history.is_empty() {
308        return "N/A".into();
309    }
310    let mut hours = [0u32; 24];
311    for session in &data.session_history {
312        use chrono::Timelike;
313        let hour = session.completed_at.with_timezone(&chrono::Local).hour();
314        hours[hour as usize] += session.minutes;
315    }
316
317    if let Some((hour, &mins)) = hours.iter().enumerate().max_by_key(|&(_, &c)| c) {
318        if mins > 0 {
319            let ampm = if hour < 12 { "AM" } else { "PM" };
320            let h = if hour == 0 {
321                12
322            } else if hour > 12 {
323                hour - 12
324            } else {
325                hour
326            };
327            return format!("{}{} ({}m)", h, ampm, mins);
328        }
329    }
330    "N/A".into()
331}
332
333pub fn queue_empty(data: &AppData) -> bool {
334    pending_tasks(data).next().is_none()
335}
336
337pub fn update_goal_streak(data: &mut AppData) -> Result<()> {
338    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
339    if data.today_focus_minutes < data.daily_goal_minutes {
340        return Ok(());
341    }
342    match &data.last_goal_date {
343        Some(last) if last == &today => {}
344        Some(last) => {
345            let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
346            let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
347            if let (Some(l), Some(t)) = (last_date, today_date) {
348                if t.succ_opt() == Some(l) {
349                    data.goal_streak_days = data.goal_streak_days.saturating_add(1);
350                } else if t != l {
351                    data.goal_streak_days = 1;
352                }
353            } else {
354                data.goal_streak_days = 1;
355            }
356        }
357        None => data.goal_streak_days = 1,
358    }
359    data.last_goal_date = Some(today);
360    Ok(())
361}
362
363pub fn record_break_session(
364    db: &Database,
365    data: &mut AppData,
366    mode: TimerMode,
367    minutes: u32,
368) -> Result<()> {
369    if !data.log_breaks {
370        return Ok(());
371    }
372    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
373    let record = FocusSessionRecord {
374        date: today,
375        minutes: minutes.max(1),
376        task_id: None,
377        mode,
378        completed_at: Utc::now(),
379    };
380    db.insert_focus_session(&record)?;
381    data.total_sessions = data.total_sessions.saturating_add(1);
382    db.persist_session_stats(data)?;
383    Ok(())
384}
385
386pub fn delete_session(db: &Database, data: &mut AppData, id: i64) -> Result<()> {
387    let stored = db.get_session(id)?;
388    let r = &stored.record;
389    if matches!(r.mode, TimerMode::Focus | TimerMode::Custom) {
390        data.total_focus_minutes = data.total_focus_minutes.saturating_sub(r.minutes);
391        data.today_focus_minutes = data.today_focus_minutes.saturating_sub(r.minutes);
392    }
393    data.total_sessions = data.total_sessions.saturating_sub(1);
394    if let Some(tid) = r.task_id {
395        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
396            t.actual_minutes = t.actual_minutes.saturating_sub(r.minutes);
397            t.sessions = t.sessions.saturating_sub(1);
398            db.upsert_task(t)?;
399        }
400    }
401    db.delete_focus_session(id)?;
402    db.persist_session_stats(data)?;
403    Ok(())
404}
405
406pub fn adjust_session_minutes(
407    db: &Database,
408    data: &mut AppData,
409    id: i64,
410    new_minutes: u32,
411) -> Result<()> {
412    let stored = db.get_session(id)?;
413    let old = stored.record.minutes;
414    let new_minutes = new_minutes.clamp(1, 480);
415    if old == new_minutes {
416        return Ok(());
417    }
418    if matches!(stored.record.mode, TimerMode::Focus | TimerMode::Custom) {
419        let delta = new_minutes as i32 - old as i32;
420        if delta > 0 {
421            data.total_focus_minutes = data.total_focus_minutes.saturating_add(delta as u32);
422            data.today_focus_minutes = data.today_focus_minutes.saturating_add(delta as u32);
423        } else {
424            data.total_focus_minutes = data.total_focus_minutes.saturating_sub((-delta) as u32);
425            data.today_focus_minutes = data.today_focus_minutes.saturating_sub((-delta) as u32);
426        }
427    }
428    if let Some(tid) = stored.record.task_id {
429        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
430            if new_minutes > old {
431                t.actual_minutes = t.actual_minutes.saturating_add(new_minutes - old);
432            } else {
433                t.actual_minutes = t.actual_minutes.saturating_sub(old - new_minutes);
434            }
435            db.upsert_task(t)?;
436        }
437    }
438    db.update_session_minutes(id, new_minutes)?;
439    update_goal_streak(data)?;
440    db.persist_session_stats(data)?;
441    Ok(())
442}
443
444pub fn sessions_remaining_hint(task: &Task, focus_minutes: u32) -> u32 {
445    if task.estimated_minutes <= task.actual_minutes {
446        return 0;
447    }
448    let left = task.estimated_minutes - task.actual_minutes;
449    let session = focus_minutes.max(1);
450    left.div_ceil(session)
451}