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}