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}