Skip to main content

void/
storage.rs

1use anyhow::Result;
2use chrono::{Datelike, Utc};
3
4use crate::db::Database;
5use crate::model::{
6    AppData, FocusSessionRecord, Priority, Subtask, Task, TaskRecurrence, TaskStatus, TimerMode,
7    TimerPreset,
8};
9
10pub fn next_id(db: &Database, data: &mut AppData) -> Result<u64> {
11    let id = data.next_id;
12    data.next_id = data.next_id.saturating_add(1);
13    db.set_setting("next_id", data.next_id.to_string())?;
14    Ok(id)
15}
16
17pub fn ensure_today_reset(db: &Database, data: &mut AppData) -> Result<()> {
18    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
19    if data.today_date.as_deref() != Some(today.as_str()) {
20        data.today_focus_minutes = 0;
21        data.today_date = Some(today.clone());
22        db.set_setting("today_focus_minutes", "0")?;
23        db.set_setting("today_date", &today)?;
24    }
25    Ok(())
26}
27
28pub fn parse_tags(input: &str) -> Vec<String> {
29    input
30        .split(',')
31        .map(|s| s.trim().to_string())
32        .filter(|s| !s.is_empty())
33        .collect()
34}
35
36pub fn normalize_due_date(input: &str, allow_past: bool) -> Result<Option<String>, String> {
37    let s = input.trim();
38    if s.is_empty() {
39        return Ok(None);
40    }
41    match chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
42        Ok(parsed) => {
43            if !allow_past {
44                let today = chrono::Local::now().date_naive();
45                if parsed < today {
46                    return Err("Due date cannot be in the past.".into());
47                }
48            }
49            Ok(Some(s.to_string()))
50        }
51        Err(_) => match s.to_lowercase().as_str() {
52            "today" => Ok(Some(chrono::Local::now().format("%Y-%m-%d").to_string())),
53            "tomorrow" => Ok(Some(
54                (chrono::Local::now() + chrono::Duration::days(1))
55                    .format("%Y-%m-%d")
56                    .to_string(),
57            )),
58            _ => Err("Due date must be YYYY-MM-DD, 'today', or 'tomorrow'.".into()),
59        },
60    }
61}
62
63#[derive(Default)]
64pub struct SessionMeta {
65    pub note: String,
66    pub tags: Vec<String>,
67    pub pause_count: u32,
68    pub pause_seconds: u32,
69}
70
71pub struct TaskPayload {
72    pub title: String,
73    pub notes: String,
74    pub estimated_minutes: u32,
75    pub priority: Priority,
76    pub tags: Vec<String>,
77    pub due_date: Option<String>,
78}
79
80pub fn add_task_full(db: &Database, data: &mut AppData, payload: TaskPayload) -> Result<u64> {
81    let id = next_id(db, data)?;
82    let mut task = Task::new(id, payload.title);
83    task.notes = payload.notes;
84    task.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
85    task.priority = payload.priority;
86    task.tags = payload.tags;
87    task.due_date = payload.due_date;
88    db.upsert_task(&task)?;
89    data.tasks.push(task);
90    Ok(id)
91}
92
93pub fn update_task(db: &Database, data: &mut AppData, id: u64, payload: TaskPayload) -> Result<()> {
94    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
95        t.title = payload.title;
96        t.notes = payload.notes;
97        t.estimated_minutes = payload.estimated_minutes.clamp(1, 480);
98        t.priority = payload.priority;
99        t.tags = payload.tags;
100        t.due_date = payload.due_date;
101        db.upsert_task(t)?;
102    }
103    Ok(())
104}
105
106pub fn delete_task(db: &Database, data: &mut AppData, id: u64) -> Result<bool> {
107    let before = data.tasks.len();
108    data.tasks.retain(|t| t.id != id);
109    if before == data.tasks.len() {
110        return Ok(false);
111    }
112    db.delete_task(id)?;
113    if data.active_task_id == Some(id) {
114        data.active_task_id = None;
115        db.persist_active_task(None)?;
116    }
117    Ok(true)
118}
119
120pub fn promote_task_on_activate(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
121    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
122        if t.status == TaskStatus::Pending {
123            t.status = TaskStatus::InProgress;
124            db.upsert_task(t)?;
125        }
126    }
127    Ok(())
128}
129
130pub fn mark_task_done(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
131    let (recurrence, title, notes, priority, tags, due_date, estimated, subtasks, blocked_by) = {
132        let Some(t) = data.tasks.iter().find(|t| t.id == id) else {
133            return Ok(());
134        };
135        (
136            t.recurrence,
137            t.title.clone(),
138            t.notes.clone(),
139            t.priority,
140            t.tags.clone(),
141            t.due_date.clone(),
142            t.estimated_minutes,
143            t.subtasks.clone(),
144            t.blocked_by.clone(),
145        )
146    };
147    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
148        t.status = TaskStatus::Done;
149        t.completed_at = Some(Utc::now());
150        db.upsert_task(t)?;
151    }
152    if recurrence != TaskRecurrence::None {
153        spawn_recurring_task(
154            db,
155            data,
156            RecurringSpawn {
157                recurrence,
158                title,
159                notes,
160                priority,
161                tags,
162                due_date,
163                estimated,
164                subtasks,
165                blocked_by,
166            },
167        )?;
168    }
169    Ok(())
170}
171
172struct RecurringSpawn {
173    recurrence: TaskRecurrence,
174    title: String,
175    notes: String,
176    priority: Priority,
177    tags: Vec<String>,
178    due_date: Option<String>,
179    estimated: u32,
180    subtasks: Vec<Subtask>,
181    blocked_by: Vec<u64>,
182}
183
184fn spawn_recurring_task(db: &Database, data: &mut AppData, spawn: RecurringSpawn) -> Result<()> {
185    let RecurringSpawn {
186        recurrence,
187        title,
188        notes,
189        priority,
190        tags,
191        due_date,
192        estimated,
193        subtasks,
194        blocked_by,
195    } = spawn;
196    let id = next_id(db, data)?;
197    let mut task = Task::new(id, title);
198    task.notes = notes;
199    task.priority = priority;
200    task.tags = tags;
201    task.estimated_minutes = estimated;
202    task.recurrence = recurrence;
203    task.blocked_by = blocked_by;
204    task.subtasks = subtasks
205        .into_iter()
206        .enumerate()
207        .map(|(i, mut s)| {
208            s.id = id * 1000 + i as u64 + 1;
209            s.done = false;
210            s
211        })
212        .collect();
213    task.due_date = next_due_date(recurrence, due_date.as_deref());
214    db.upsert_task(&task)?;
215    data.tasks.push(task);
216    Ok(())
217}
218
219fn next_due_date(recurrence: TaskRecurrence, current: Option<&str>) -> Option<String> {
220    use chrono::{Datelike, NaiveDate, Weekday};
221    let today = chrono::Local::now().date_naive();
222    match recurrence {
223        TaskRecurrence::None => current.map(String::from),
224        TaskRecurrence::Daily => Some(
225            (today + chrono::Duration::days(1))
226                .format("%Y-%m-%d")
227                .to_string(),
228        ),
229        TaskRecurrence::Weekly => {
230            let base = current
231                .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
232                .unwrap_or(today);
233            Some(
234                (base + chrono::Duration::days(7))
235                    .format("%Y-%m-%d")
236                    .to_string(),
237            )
238        }
239        TaskRecurrence::Weekdays => {
240            let mut d = today + chrono::Duration::days(1);
241            while matches!(d.weekday(), Weekday::Sat | Weekday::Sun) {
242                d += chrono::Duration::days(1);
243            }
244            Some(d.format("%Y-%m-%d").to_string())
245        }
246    }
247}
248
249pub fn cycle_task_status(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
250    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
251        match t.status {
252            TaskStatus::Pending => t.status = TaskStatus::InProgress,
253            TaskStatus::InProgress => {
254                t.status = TaskStatus::Done;
255                t.completed_at = Some(Utc::now());
256            }
257            TaskStatus::Done => {
258                t.status = TaskStatus::Pending;
259                t.completed_at = None;
260            }
261        }
262        db.upsert_task(t)?;
263    }
264    Ok(())
265}
266
267pub fn toggle_today(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
268    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
269        t.today = !t.today;
270        db.upsert_task(t)?;
271    }
272    Ok(())
273}
274
275pub fn set_priority(db: &Database, data: &mut AppData, id: u64, priority: Priority) -> Result<()> {
276    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
277        t.priority = priority;
278        db.upsert_task(t)?;
279    }
280    Ok(())
281}
282
283pub fn move_task(db: &Database, data: &mut AppData, id: u64, delta: i32) -> Result<()> {
284    let idx = match data.tasks.iter().position(|t| t.id == id) {
285        Some(i) => i,
286        None => return Ok(()),
287    };
288    let new_idx = (idx as i32 + delta).clamp(0, data.tasks.len() as i32 - 1) as usize;
289    if idx != new_idx {
290        let task = data.tasks.remove(idx);
291        data.tasks.insert(new_idx, task);
292        for (i, t) in data.tasks.iter_mut().enumerate() {
293            t.sort_order = i as u32;
294        }
295        db.sync_sort_orders(&data.tasks)?;
296    }
297    Ok(())
298}
299
300pub fn pick_best_task(data: &AppData) -> Option<u64> {
301    data.tasks
302        .iter()
303        .filter(|t| t.status != TaskStatus::Done)
304        .max_by(|a, b| {
305            a.priority
306                .rank()
307                .cmp(&b.priority.rank())
308                .then(b.today.cmp(&a.today))
309                .then(a.sort_order.cmp(&b.sort_order))
310        })
311        .map(|t| t.id)
312}
313
314pub fn advance_to_next_task(data: &AppData, current: Option<u64>) -> Option<u64> {
315    let pending: Vec<&Task> = data
316        .tasks
317        .iter()
318        .filter(|t| t.status != TaskStatus::Done)
319        .collect();
320    if pending.is_empty() {
321        return None;
322    }
323    if let Some(cur) = current {
324        if let Some(pos) = pending.iter().position(|t| t.id == cur) {
325            let next = (pos + 1) % pending.len();
326            return Some(pending[next].id);
327        }
328    }
329    pick_best_task(data)
330}
331
332pub fn record_focus_session(
333    db: &Database,
334    data: &mut AppData,
335    minutes: u32,
336    task_id: Option<u64>,
337    mode: TimerMode,
338) -> Result<()> {
339    record_focus_session_with_meta(db, data, minutes, task_id, mode, SessionMeta::default())
340}
341
342pub fn record_focus_session_with_meta(
343    db: &Database,
344    data: &mut AppData,
345    minutes: u32,
346    task_id: Option<u64>,
347    mode: TimerMode,
348    meta: SessionMeta,
349) -> Result<()> {
350    ensure_today_reset(db, data)?;
351    let mins = minutes.max(1);
352    data.total_focus_minutes = data.total_focus_minutes.saturating_add(mins);
353    data.today_focus_minutes = data.today_focus_minutes.saturating_add(mins);
354    data.total_sessions = data.total_sessions.saturating_add(1);
355
356    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
357    match &data.last_session_date {
358        Some(last) if last == &today => {}
359        Some(last) => {
360            let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
361            let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
362            if let (Some(l), Some(t)) = (last_date, today_date) {
363                if t.succ_opt() == Some(l) {
364                    data.streak_days = data.streak_days.saturating_add(1);
365                } else if t != l {
366                    data.streak_days = 1;
367                }
368            } else {
369                data.streak_days = 1;
370            }
371        }
372        None => data.streak_days = 1,
373    }
374    data.last_session_date = Some(today.clone());
375    data.today_date = Some(today.clone());
376
377    let record = FocusSessionRecord {
378        date: today.clone(),
379        minutes: mins,
380        task_id,
381        mode,
382        completed_at: Utc::now(),
383        note: meta.note,
384        tags: meta.tags,
385        pause_count: meta.pause_count,
386        pause_seconds: meta.pause_seconds,
387    };
388    db.insert_focus_session(&record)?;
389    update_goal_streak(data)?;
390    update_period_streaks(data, &today)?;
391    db.persist_session_stats(data)?;
392
393    if let Some(id) = task_id {
394        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
395            t.actual_minutes = t.actual_minutes.saturating_add(mins);
396            t.sessions = t.sessions.saturating_add(1);
397            if t.status == TaskStatus::Pending {
398                t.status = TaskStatus::InProgress;
399            }
400            db.upsert_task(t)?;
401        }
402    }
403    Ok(())
404}
405
406pub fn today_focus_minutes(data: &AppData) -> u32 {
407    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
408    if data.today_date.as_deref() == Some(today.as_str()) {
409        data.today_focus_minutes
410    } else {
411        0
412    }
413}
414
415pub fn minutes_by_date(db: &Database, days: usize) -> Result<Vec<(String, u32)>> {
416    db.minutes_by_date(days)
417}
418
419pub fn focus_heatmap(db: &Database) -> Result<Vec<(String, u32)>> {
420    db.focus_minutes_grouped()
421}
422
423pub fn pending_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
424    data.tasks
425        .iter()
426        .filter(|t| t.status != TaskStatus::Done && !t.archived)
427}
428
429pub fn sorted_pending_tasks(data: &AppData) -> Vec<&Task> {
430    let mut tasks: Vec<&Task> = pending_tasks(data).collect();
431    tasks.sort_by(|a, b| {
432        b.priority
433            .rank()
434            .cmp(&a.priority.rank())
435            .then(b.today.cmp(&a.today))
436            .then(a.sort_order.cmp(&b.sort_order))
437    });
438    tasks
439}
440
441pub fn completed_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
442    data.tasks.iter().filter(|t| t.status == TaskStatus::Done)
443}
444
445pub fn most_productive_hour_label(data: &AppData) -> String {
446    if data.session_history.is_empty() {
447        return "N/A".into();
448    }
449    let mut hours = [0u32; 24];
450    for session in &data.session_history {
451        use chrono::Timelike;
452        let hour = session.completed_at.with_timezone(&chrono::Local).hour();
453        hours[hour as usize] += session.minutes;
454    }
455
456    if let Some((hour, &mins)) = hours.iter().enumerate().max_by_key(|&(_, &c)| c) {
457        if mins > 0 {
458            let ampm = if hour < 12 { "AM" } else { "PM" };
459            let h = if hour == 0 {
460                12
461            } else if hour > 12 {
462                hour - 12
463            } else {
464                hour
465            };
466            return format!("{}{} ({}m)", h, ampm, mins);
467        }
468    }
469    "N/A".into()
470}
471
472pub fn queue_empty(data: &AppData) -> bool {
473    pending_tasks(data).next().is_none()
474}
475
476pub fn update_goal_streak(data: &mut AppData) -> Result<()> {
477    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
478    if data.today_focus_minutes < data.daily_goal_minutes {
479        return Ok(());
480    }
481    match &data.last_goal_date {
482        Some(last) if last == &today => {}
483        Some(last) => {
484            let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").ok();
485            let today_date = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d").ok();
486            if let (Some(l), Some(t)) = (last_date, today_date) {
487                if t.succ_opt() == Some(l) {
488                    data.goal_streak_days = data.goal_streak_days.saturating_add(1);
489                } else if t != l {
490                    data.goal_streak_days = 1;
491                }
492            } else {
493                data.goal_streak_days = 1;
494            }
495        }
496        None => data.goal_streak_days = 1,
497    }
498    data.last_goal_date = Some(today);
499    Ok(())
500}
501
502pub fn record_break_session(
503    db: &Database,
504    data: &mut AppData,
505    mode: TimerMode,
506    minutes: u32,
507) -> Result<()> {
508    if !data.log_breaks {
509        return Ok(());
510    }
511    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
512    let record = FocusSessionRecord {
513        date: today,
514        minutes: minutes.max(1),
515        task_id: None,
516        mode,
517        completed_at: Utc::now(),
518        note: String::new(),
519        tags: Vec::new(),
520        pause_count: 0,
521        pause_seconds: 0,
522    };
523    db.insert_focus_session(&record)?;
524    data.total_sessions = data.total_sessions.saturating_add(1);
525    db.persist_session_stats(data)?;
526    Ok(())
527}
528
529pub fn delete_session(db: &Database, data: &mut AppData, id: i64) -> Result<()> {
530    let stored = db.get_session(id)?;
531    let r = &stored.record;
532    if matches!(r.mode, TimerMode::Focus | TimerMode::Custom) {
533        data.total_focus_minutes = data.total_focus_minutes.saturating_sub(r.minutes);
534        data.today_focus_minutes = data.today_focus_minutes.saturating_sub(r.minutes);
535    }
536    data.total_sessions = data.total_sessions.saturating_sub(1);
537    if let Some(tid) = r.task_id {
538        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
539            t.actual_minutes = t.actual_minutes.saturating_sub(r.minutes);
540            t.sessions = t.sessions.saturating_sub(1);
541            db.upsert_task(t)?;
542        }
543    }
544    db.delete_focus_session(id)?;
545    db.persist_session_stats(data)?;
546    Ok(())
547}
548
549pub fn adjust_session_minutes(
550    db: &Database,
551    data: &mut AppData,
552    id: i64,
553    new_minutes: u32,
554) -> Result<()> {
555    let stored = db.get_session(id)?;
556    let old = stored.record.minutes;
557    let new_minutes = new_minutes.clamp(1, 480);
558    if old == new_minutes {
559        return Ok(());
560    }
561    if matches!(stored.record.mode, TimerMode::Focus | TimerMode::Custom) {
562        let delta = new_minutes as i32 - old as i32;
563        if delta > 0 {
564            data.total_focus_minutes = data.total_focus_minutes.saturating_add(delta as u32);
565            data.today_focus_minutes = data.today_focus_minutes.saturating_add(delta as u32);
566        } else {
567            data.total_focus_minutes = data.total_focus_minutes.saturating_sub((-delta) as u32);
568            data.today_focus_minutes = data.today_focus_minutes.saturating_sub((-delta) as u32);
569        }
570    }
571    if let Some(tid) = stored.record.task_id {
572        if let Some(t) = data.tasks.iter_mut().find(|t| t.id == tid) {
573            if new_minutes > old {
574                t.actual_minutes = t.actual_minutes.saturating_add(new_minutes - old);
575            } else {
576                t.actual_minutes = t.actual_minutes.saturating_sub(old - new_minutes);
577            }
578            db.upsert_task(t)?;
579        }
580    }
581    db.update_session_minutes(id, new_minutes)?;
582    update_goal_streak(data)?;
583    db.persist_session_stats(data)?;
584    Ok(())
585}
586
587pub fn sessions_remaining_hint(task: &Task, focus_minutes: u32) -> u32 {
588    if task.estimated_minutes <= task.actual_minutes {
589        return 0;
590    }
591    let left = task.estimated_minutes - task.actual_minutes;
592    let session = focus_minutes.max(1);
593    left.div_ceil(session)
594}
595
596pub fn archive_task(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
597    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
598        t.archived = true;
599        db.upsert_task(t)?;
600    }
601    Ok(())
602}
603
604pub fn unarchive_task(db: &Database, data: &mut AppData, id: u64) -> Result<()> {
605    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
606        t.archived = false;
607        db.upsert_task(t)?;
608    }
609    Ok(())
610}
611
612pub fn auto_archive_old_tasks(db: &Database, data: &mut AppData) -> Result<u32> {
613    let days = data.archive_after_days;
614    if days == 0 {
615        return Ok(0);
616    }
617    let cutoff = (chrono::Local::now() - chrono::Duration::days(days as i64))
618        .format("%Y-%m-%d")
619        .to_string();
620    let mut count = 0u32;
621    for t in data
622        .tasks
623        .iter_mut()
624        .filter(|t| t.status == TaskStatus::Done && !t.archived)
625    {
626        if let Some(ref completed) = t.completed_at {
627            let key = completed.format("%Y-%m-%d").to_string();
628            if key.as_str() < cutoff.as_str() {
629                t.archived = true;
630                db.upsert_task(t)?;
631                count += 1;
632            }
633        }
634    }
635    Ok(count)
636}
637
638pub fn archived_tasks(data: &AppData) -> impl Iterator<Item = &Task> {
639    data.tasks.iter().filter(|t| t.archived)
640}
641
642pub fn toggle_subtask(
643    db: &Database,
644    data: &mut AppData,
645    task_id: u64,
646    subtask_id: u64,
647) -> Result<()> {
648    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == task_id) {
649        if let Some(s) = t.subtasks.iter_mut().find(|s| s.id == subtask_id) {
650            s.done = !s.done;
651            db.upsert_task(t)?;
652        }
653    }
654    Ok(())
655}
656
657pub fn add_subtask(db: &Database, data: &mut AppData, task_id: u64, title: String) -> Result<()> {
658    let id = next_id(db, data)?;
659    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == task_id) {
660        t.subtasks.push(Subtask {
661            id,
662            title,
663            done: false,
664        });
665        db.upsert_task(t)?;
666    }
667    Ok(())
668}
669
670pub fn delete_subtask(
671    db: &Database,
672    data: &mut AppData,
673    task_id: u64,
674    subtask_id: u64,
675) -> Result<()> {
676    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == task_id) {
677        let before = t.subtasks.len();
678        t.subtasks.retain(|s| s.id != subtask_id);
679        if t.subtasks.len() != before {
680            db.upsert_task(t)?;
681        }
682    }
683    Ok(())
684}
685
686pub fn set_task_recurrence(
687    db: &Database,
688    data: &mut AppData,
689    id: u64,
690    recurrence: TaskRecurrence,
691) -> Result<()> {
692    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
693        t.recurrence = recurrence;
694        db.upsert_task(t)?;
695    }
696    Ok(())
697}
698
699pub fn set_blocked_by(
700    db: &Database,
701    data: &mut AppData,
702    id: u64,
703    blockers: Vec<u64>,
704) -> Result<()> {
705    if let Some(t) = data.tasks.iter_mut().find(|t| t.id == id) {
706        t.blocked_by = blockers;
707        db.upsert_task(t)?;
708    }
709    Ok(())
710}
711
712pub fn bulk_mark_done(db: &Database, data: &mut AppData, ids: &[u64]) -> Result<u32> {
713    let mut count = 0;
714    for &id in ids {
715        mark_task_done(db, data, id)?;
716        count += 1;
717    }
718    Ok(count)
719}
720
721pub fn bulk_delete(db: &Database, data: &mut AppData, ids: &[u64]) -> Result<u32> {
722    let mut count = 0;
723    for &id in ids {
724        if delete_task(db, data, id)? {
725            count += 1;
726        }
727    }
728    Ok(count)
729}
730
731pub fn overdue_and_due_today(data: &AppData) -> (Vec<u64>, Vec<u64>) {
732    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
733    let mut overdue = Vec::new();
734    let mut due_today = Vec::new();
735    for t in data
736        .tasks
737        .iter()
738        .filter(|t| t.status != TaskStatus::Done && !t.archived)
739    {
740        if let Some(ref due) = t.due_date {
741            if due.as_str() < today.as_str() {
742                overdue.push(t.id);
743            } else if due.as_str() == today.as_str() {
744                due_today.push(t.id);
745            }
746        }
747    }
748    (overdue, due_today)
749}
750
751pub fn focus_score(data: &AppData) -> u32 {
752    let today_mins = today_focus_minutes(data);
753    let goal = data.daily_goal_minutes.max(1);
754    let goal_pct = ((today_mins as f64 / goal as f64) * 40.0).min(40.0);
755
756    let ratio_pct = if data.total_focus_minutes > 0 {
757        30.0
758    } else {
759        0.0
760    };
761
762    let streak_pct = (data.streak_days.min(14) as f64 / 14.0) * 30.0;
763
764    (goal_pct + ratio_pct + streak_pct)
765        .round()
766        .clamp(0.0, 100.0) as u32
767}
768
769fn update_period_streaks(data: &mut AppData, today: &str) -> Result<()> {
770    let today_date = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d").ok();
771    let Some(today_date) = today_date else {
772        return Ok(());
773    };
774
775    let week_key = format!("{}-W{:02}", today_date.year(), today_date.iso_week().week());
776    match &data.last_weekly_streak_key {
777        Some(last) if last == &week_key => {}
778        Some(last) => {
779            if is_consecutive_week(last, &week_key) {
780                data.weekly_streak_weeks = data.weekly_streak_weeks.saturating_add(1);
781            } else {
782                data.weekly_streak_weeks = 1;
783            }
784            data.last_weekly_streak_key = Some(week_key);
785        }
786        None => {
787            data.weekly_streak_weeks = 1;
788            data.last_weekly_streak_key = Some(week_key);
789        }
790    }
791
792    let month_key = format!("{}-{:02}", today_date.year(), today_date.month());
793    match &data.last_monthly_streak_key {
794        Some(last) if last == &month_key => {}
795        Some(last) => {
796            if is_consecutive_month(last, &month_key) {
797                data.monthly_streak_months = data.monthly_streak_months.saturating_add(1);
798            } else {
799                data.monthly_streak_months = 1;
800            }
801            data.last_monthly_streak_key = Some(month_key);
802        }
803        None => {
804            data.monthly_streak_months = 1;
805            data.last_monthly_streak_key = Some(month_key);
806        }
807    }
808    Ok(())
809}
810
811fn is_consecutive_week(prev: &str, cur: &str) -> bool {
812    week_offset(prev)
813        .zip(week_offset(cur))
814        .is_some_and(|(a, b)| b == a + 1)
815}
816
817fn is_consecutive_month(prev: &str, cur: &str) -> bool {
818    month_offset(prev)
819        .zip(month_offset(cur))
820        .is_some_and(|(a, b)| b == a + 1)
821}
822
823fn week_offset(key: &str) -> Option<i32> {
824    let (y, w) = key.split_once("-W")?;
825    Some(y.parse::<i32>().ok()? * 100 + w.parse::<i32>().ok()?)
826}
827
828fn month_offset(key: &str) -> Option<i32> {
829    let (y, m) = key.split_once('-')?;
830    Some(y.parse::<i32>().ok()? * 100 + m.parse::<i32>().ok()?)
831}
832
833pub fn apply_timer_preset(data: &mut AppData, preset: &TimerPreset) {
834    data.focus_minutes = preset.focus_minutes;
835    data.short_break_minutes = preset.short_break_minutes;
836    data.long_break_minutes = preset.long_break_minutes;
837    data.long_break_every = preset.long_break_every;
838    data.active_preset = Some(preset.name.clone());
839}
840
841pub fn cycle_timer_preset(data: &mut AppData) -> Option<TimerPreset> {
842    if data.timer_presets.is_empty() {
843        return None;
844    }
845    let next = match &data.active_preset {
846        None => data.timer_presets[0].clone(),
847        Some(name) => {
848            let idx = data
849                .timer_presets
850                .iter()
851                .position(|p| &p.name == name)
852                .unwrap_or(0);
853            data.timer_presets[(idx + 1) % data.timer_presets.len()].clone()
854        }
855    };
856    apply_timer_preset(data, &next);
857    Some(next)
858}