Skip to main content

max / goingson

16.5 KB · 463 lines History Blame Raw
1 //! Monthly review aggregation logic.
2 //!
3 //! Contains pure functions for computing monthly review data.
4 //! All I/O is done by the command layer; this module only transforms
5 //! pre-fetched data into the final response shape.
6
7 use chrono::{Datelike, Duration, NaiveDate, Utc};
8 use chrono_tz::Tz;
9 use serde::Serialize;
10 use std::collections::HashMap;
11
12 use crate::date_utils::civil_midnight_utc;
13 use crate::id_types::ProjectId;
14 use crate::models::{Event, MonthlyGoal, MonthlyReflection, Task, TaskStatus};
15 use crate::weekly_review::{compute_project_health, ProjectHealth};
16
17 // ============ Types ============
18
19 /// Pre-computed monthly review data for the frontend.
20 #[derive(Debug, Serialize)]
21 #[serde(rename_all = "camelCase")]
22 pub struct MonthlyReviewData {
23 /// Month in YYYY-MM format.
24 pub month: String,
25 /// Human-readable display (e.g., "April 2026").
26 pub month_display: String,
27 /// First day of the month.
28 pub month_start_date: String,
29 /// Last day of the month.
30 pub month_end_date: String,
31
32 // ===== Heat Map =====
33 /// Per-day data for the calendar heat map.
34 pub days: Vec<MonthDayData>,
35 /// Number of weeks (rows) the calendar grid needs.
36 pub week_count: u32,
37 /// Day-of-week offset for the 1st (0=Mon, 6=Sun).
38 pub first_day_offset: u32,
39
40 // ===== Stats =====
41 pub tasks_completed_count: usize,
42 /// Up to 6 completed tasks for the Accomplished card.
43 pub tasks_completed_top: Vec<Task>,
44 pub tasks_created_count: usize,
45 pub events_count: usize,
46 /// Busiest day (most completed tasks).
47 pub busiest_day: Option<String>,
48 /// Quietest day (fewest completed tasks, at least 1 day in past).
49 pub quietest_day: Option<String>,
50 /// Longest streak of consecutive days with completed tasks.
51 pub completion_streak: u32,
52
53 // ===== Project Pulse =====
54 pub project_pulse: Vec<ProjectPulse>,
55
56 // ===== Project Health =====
57 pub project_health: Vec<ProjectHealth>,
58
59 // ===== Goals & Reflection =====
60 pub goals: Vec<MonthlyGoal>,
61 pub reflection: Option<MonthlyReflection>,
62
63 // ===== Patterns =====
64 pub patterns: Vec<String>,
65 }
66
67 /// Per-day data for the calendar heat map grid.
68 #[derive(Debug, Clone, Serialize)]
69 #[serde(rename_all = "camelCase")]
70 pub struct MonthDayData {
71 /// Date in YYYY-MM-DD format.
72 pub date: String,
73 /// Day of month (1-31).
74 pub day_number: u32,
75 /// Whether this is today.
76 pub is_today: bool,
77 /// Whether this day is in the past.
78 pub is_past: bool,
79 /// Whether this day is a vacation day.
80 pub is_vacation: bool,
81 /// Number of tasks completed on this day.
82 pub completed_count: i32,
83 /// Number of events on this day.
84 pub event_count: i32,
85 /// Activity intensity level: 0 (none), 1 (low), 2 (medium), 3 (high).
86 pub intensity: u8,
87 }
88
89 /// Per-project pulse showing net progress direction.
90 #[derive(Debug, Clone, Serialize)]
91 #[serde(rename_all = "camelCase")]
92 pub struct ProjectPulse {
93 pub id: ProjectId,
94 pub name: String,
95 /// Tasks completed this month in this project.
96 pub completed: i32,
97 /// Tasks created this month in this project.
98 pub created: i32,
99 /// "growing" if created > completed, "shrinking" if completed > created, "stable" otherwise.
100 pub direction: String,
101 }
102
103 /// All data needed to compute the monthly review, pre-fetched by the command layer.
104 pub struct MonthlyReviewInput {
105 pub month_start: NaiveDate,
106 pub month_end: NaiveDate,
107 pub tasks_completed: Vec<Task>,
108 pub tasks_created: Vec<Task>,
109 pub events: Vec<Event>,
110 pub all_tasks: Vec<Task>,
111 pub projects: Vec<crate::models::Project>,
112 pub goals: Vec<MonthlyGoal>,
113 pub reflection: Option<MonthlyReflection>,
114 pub vacation_days: Vec<NaiveDate>,
115 /// User's timezone, for local-day bucketing of the heat-map and streak.
116 pub tz: Tz,
117 }
118
119 // ============ Date Helpers ============
120
121 /// Gets the first day of the current month.
122 pub fn current_month_start() -> NaiveDate {
123 let today = Utc::now().date_naive();
124 NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("day 1 is always valid")
125 }
126
127 /// Gets the last day of the month.
128 pub fn month_end(month_start: NaiveDate) -> NaiveDate {
129 // Move to next month day 1, then subtract 1 day
130 let (next_year, next_month) = if month_start.month() == 12 {
131 (month_start.year() + 1, 1)
132 } else {
133 (month_start.year(), month_start.month() + 1)
134 };
135 NaiveDate::from_ymd_opt(next_year, next_month, 1)
136 .expect("next month day 1 is valid")
137 - Duration::days(1)
138 }
139
140 /// Formats a month for display (e.g., "April 2026").
141 pub fn format_month_display(month_start: NaiveDate) -> String {
142 month_start.format("%B %Y").to_string()
143 }
144
145 /// Parses a YYYY-MM string into the first day of that month.
146 pub fn parse_month(month_str: &str) -> Option<NaiveDate> {
147 let parts: Vec<&str> = month_str.split('-').collect();
148 if parts.len() != 2 {
149 return None;
150 }
151 let year: i32 = parts[0].parse().ok()?;
152 let month: u32 = parts[1].parse().ok()?;
153 NaiveDate::from_ymd_opt(year, month, 1)
154 }
155
156 // ============ Pure Aggregation ============
157
158 /// Computes the full monthly review from pre-fetched data.
159 pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData {
160 let tz = input.tz;
161 let today = Utc::now().with_timezone(&tz).date_naive();
162 let month_start = input.month_start;
163 let month_end_date = input.month_end;
164 let days_in_month = (month_end_date - month_start).num_days() as u32 + 1;
165
166 // Calendar grid layout
167 let first_day_offset = month_start.weekday().num_days_from_monday();
168 let week_count = (first_day_offset + days_in_month).div_ceil(7);
169
170 // Build per-day data
171 let days: Vec<MonthDayData> = (0..days_in_month)
172 .map(|day_offset| {
173 let date = month_start + Duration::days(day_offset as i64);
174 // Local-day boundaries so heat-map/streak attribution respects the
175 // user's calendar day, not UTC midnight.
176 let day_start = civil_midnight_utc(date, tz);
177 let next_day_start = civil_midnight_utc(date + Duration::days(1), tz);
178
179 let completed_count = input.tasks_completed.iter()
180 .filter(|t| {
181 t.completed_at
182 .map(|ca| ca >= day_start && ca < next_day_start)
183 .unwrap_or(false)
184 })
185 .count() as i32;
186
187 let event_count = input.events.iter()
188 .filter(|e| e.start_time >= day_start && e.start_time < next_day_start)
189 .count() as i32;
190
191 let activity = completed_count + event_count;
192 let intensity = match activity {
193 0 => 0,
194 1..=2 => 1,
195 3..=5 => 2,
196 _ => 3,
197 };
198
199 MonthDayData {
200 date: date.format("%Y-%m-%d").to_string(),
201 day_number: date.day(),
202 is_today: date == today,
203 is_past: date < today,
204 is_vacation: input.vacation_days.contains(&date),
205 completed_count,
206 event_count,
207 intensity,
208 }
209 })
210 .collect();
211
212 // Stats
213 let tasks_completed_count = input.tasks_completed.len();
214 let tasks_completed_top: Vec<Task> = input.tasks_completed.iter().take(6).cloned().collect();
215 let tasks_created_count = input.tasks_created.len();
216 let events_count = input.events.len();
217
218 // Busiest/quietest day (only past days)
219 let past_days: Vec<_> = days.iter().filter(|d| d.is_past || d.is_today).collect();
220 let busiest_day = past_days.iter()
221 .max_by_key(|d| d.completed_count)
222 .filter(|d| d.completed_count > 0)
223 .map(|d| d.date.clone());
224 let quietest_day = past_days.iter()
225 .find(|d| d.completed_count == 0 && !d.is_vacation)
226 .or_else(|| past_days.iter().min_by_key(|d| d.completed_count))
227 .map(|d| d.date.clone());
228
229 // Completion streak
230 let completion_streak = compute_streak(&days);
231
232 // Project pulse
233 let project_pulse = compute_project_pulse(&input.tasks_completed, &input.tasks_created, &input.projects);
234
235 // Project health (reuse weekly review logic)
236 let project_health = compute_project_health(&input.projects, &input.all_tasks);
237
238 // Patterns
239 let patterns = compute_patterns(&days, &input.all_tasks, &input.projects, &input.tasks_completed);
240
241 MonthlyReviewData {
242 month: month_start.format("%Y-%m").to_string(),
243 month_display: format_month_display(month_start),
244 month_start_date: month_start.format("%Y-%m-%d").to_string(),
245 month_end_date: month_end_date.format("%Y-%m-%d").to_string(),
246
247 days,
248 week_count,
249 first_day_offset,
250
251 tasks_completed_count,
252 tasks_completed_top,
253 tasks_created_count,
254 events_count,
255 busiest_day,
256 quietest_day,
257 completion_streak,
258
259 project_pulse,
260 project_health,
261
262 goals: input.goals,
263 reflection: input.reflection,
264
265 patterns,
266 }
267 }
268
269 /// Computes the longest streak of consecutive days with completed tasks.
270 fn compute_streak(days: &[MonthDayData]) -> u32 {
271 let mut max_streak = 0u32;
272 let mut current_streak = 0u32;
273
274 for day in days {
275 if !day.is_past && !day.is_today {
276 break;
277 }
278 if day.completed_count > 0 {
279 current_streak += 1;
280 max_streak = max_streak.max(current_streak);
281 } else if !day.is_vacation {
282 current_streak = 0;
283 }
284 // Vacation days don't break the streak
285 }
286
287 max_streak
288 }
289
290 /// Computes per-project pulse data.
291 fn compute_project_pulse(
292 tasks_completed: &[Task],
293 tasks_created: &[Task],
294 projects: &[crate::models::Project],
295 ) -> Vec<ProjectPulse> {
296 let mut completed_by_project: HashMap<ProjectId, i32> = HashMap::new();
297 let mut created_by_project: HashMap<ProjectId, i32> = HashMap::new();
298
299 for task in tasks_completed {
300 if let Some(pid) = task.project_id {
301 *completed_by_project.entry(pid).or_default() += 1;
302 }
303 }
304 for task in tasks_created {
305 if let Some(pid) = task.project_id {
306 *created_by_project.entry(pid).or_default() += 1;
307 }
308 }
309
310 projects.iter()
311 .filter_map(|p| {
312 let completed = completed_by_project.get(&p.id).copied().unwrap_or(0);
313 let created = created_by_project.get(&p.id).copied().unwrap_or(0);
314
315 if completed == 0 && created == 0 {
316 return None;
317 }
318
319 let direction = if created > completed {
320 "growing"
321 } else if completed > created {
322 "shrinking"
323 } else {
324 "stable"
325 }.to_string();
326
327 Some(ProjectPulse {
328 id: p.id,
329 name: p.name.clone(),
330 completed,
331 created,
332 direction,
333 })
334 })
335 .collect()
336 }
337
338 /// Computes simple pattern observations.
339 fn compute_patterns(
340 days: &[MonthDayData],
341 all_tasks: &[Task],
342 projects: &[crate::models::Project],
343 tasks_completed: &[Task],
344 ) -> Vec<String> {
345 let mut patterns = Vec::new();
346
347 // Day-of-week productivity pattern
348 let day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
349 let mut completions_by_dow = [0i32; 7];
350 let mut days_counted_by_dow = [0i32; 7];
351
352 for day in days.iter().filter(|d| d.is_past || d.is_today) {
353 if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") {
354 let dow = date.weekday().num_days_from_monday() as usize;
355 completions_by_dow[dow] += day.completed_count;
356 days_counted_by_dow[dow] += 1;
357 }
358 }
359
360 // Find most productive day(s) of week
361 let max_completions = completions_by_dow.iter().max().copied().unwrap_or(0);
362 if max_completions > 0 {
363 let best_days: Vec<&str> = completions_by_dow.iter()
364 .enumerate()
365 .filter(|(_, c)| **c == max_completions && **c > 0)
366 .map(|(i, _)| day_names[i])
367 .collect();
368
369 if best_days.len() <= 2 && max_completions >= 3 {
370 patterns.push(format!(
371 "You completed the most tasks on {}",
372 best_days.join(" and ")
373 ));
374 }
375 }
376
377 // Chronic overdue tasks (3+ weeks overdue)
378 let now = Utc::now();
379 let chronic_overdue: Vec<_> = all_tasks.iter()
380 .filter(|t| {
381 t.is_overdue() && t.due.map(|d| (now - d).num_weeks() >= 3).unwrap_or(false)
382 })
383 .collect();
384 if !chronic_overdue.is_empty() {
385 patterns.push(format!(
386 "{} task{} been overdue for 3+ weeks",
387 chronic_overdue.len(),
388 if chronic_overdue.len() == 1 { " has" } else { "s have" }
389 ));
390 }
391
392 // Inactive projects
393 let active_project_ids: std::collections::HashSet<_> = tasks_completed.iter()
394 .filter_map(|t| t.project_id)
395 .collect();
396 let inactive_projects: Vec<_> = projects.iter()
397 .filter(|p| {
398 !active_project_ids.contains(&p.id) &&
399 all_tasks.iter().any(|t| t.project_id == Some(p.id) && (t.status == TaskStatus::Pending || t.status == TaskStatus::Started))
400 })
401 .collect();
402 for p in inactive_projects.iter().take(2) {
403 patterns.push(format!("{} had no activity this month", p.name));
404 }
405
406 patterns
407 }
408
409 #[cfg(test)]
410 mod tests {
411 use super::*;
412
413 #[test]
414 fn test_month_end() {
415 let jan = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
416 assert_eq!(month_end(jan), NaiveDate::from_ymd_opt(2026, 1, 31).unwrap());
417
418 let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
419 assert_eq!(month_end(feb), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap());
420
421 let dec = NaiveDate::from_ymd_opt(2025, 12, 1).unwrap();
422 assert_eq!(month_end(dec), NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
423 }
424
425 #[test]
426 fn test_format_month_display() {
427 let april = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
428 assert_eq!(format_month_display(april), "April 2026");
429 }
430
431 #[test]
432 fn test_parse_month() {
433 assert_eq!(
434 parse_month("2026-04"),
435 Some(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap())
436 );
437 assert_eq!(parse_month("invalid"), None);
438 assert_eq!(parse_month("2026-13"), None);
439 }
440
441 #[test]
442 fn test_compute_streak() {
443 let days = vec![
444 MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 },
445 MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: false, completed_count: 2, event_count: 0, intensity: 2 },
446 MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: false, is_past: true, is_vacation: false, completed_count: 0, event_count: 0, intensity: 0 },
447 MonthDayData { date: "2026-04-04".into(), day_number: 4, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 },
448 MonthDayData { date: "2026-04-05".into(), day_number: 5, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 },
449 ];
450 assert_eq!(compute_streak(&days), 2); // days 1-2, then broken by day 3
451 }
452
453 #[test]
454 fn test_streak_vacation_doesnt_break() {
455 let days = vec![
456 MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 },
457 MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: true, completed_count: 0, event_count: 0, intensity: 0 },
458 MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 },
459 ];
460 assert_eq!(compute_streak(&days), 2); // vacation doesn't break streak
461 }
462 }
463