Skip to main content

max / goingson

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