Skip to main content

max / goingson

26.1 KB · 749 lines History Blame Raw
1 //! Weekly review aggregation logic.
2 //!
3 //! Contains pure functions for computing weekly 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, Weekday};
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::{EventId, ProjectId};
14 use crate::models::{Event, Task, TaskStatus, WeeklyReview};
15
16 // ============ Types ============
17
18 /// Pre-computed weekly review data.
19 /// All stats, lists, and display formatting is done server-side.
20 #[derive(Debug, Serialize)]
21 #[serde(rename_all = "camelCase")]
22 pub struct WeeklyReviewData {
23 /// The Monday of the week being reviewed (YYYY-MM-DD)
24 pub week_start_date: String,
25 /// The Sunday of the week (YYYY-MM-DD)
26 pub week_end_date: String,
27 /// Human-readable date range (e.g., "Feb 10 - Feb 16")
28 pub week_display: String,
29 /// Whether this week's review is completed
30 pub is_completed: bool,
31 /// When the review was completed, if applicable
32 pub completed_at: Option<DateTime<Utc>>,
33 /// Notes from the review
34 pub notes: String,
35
36 // ===== Week Timeline =====
37 /// Timeline data for each day of the week (Mon-Sun)
38 pub timeline_days: Vec<TimelineDayData>,
39
40 // ===== Past Week Stats =====
41 /// Count of tasks completed in the past week
42 pub tasks_completed_count: usize,
43 /// Tasks completed in the past week
44 pub tasks_completed: Vec<Task>,
45 /// Count of tasks that became overdue in the past week
46 pub tasks_overdue_count: usize,
47 /// Tasks that became overdue
48 pub tasks_overdue: Vec<Task>,
49 /// Count of events that occurred in the past week
50 pub events_occurred_count: usize,
51 /// Events that occurred in the past week
52 pub events_occurred: Vec<EventSummary>,
53 /// Count of pending tasks at week end
54 pub tasks_pending_count: usize,
55 /// Tasks carried over from previous weeks (still pending, created before this week)
56 pub carried_over_tasks: Vec<Task>,
57 /// Count of carried over tasks
58 pub carried_over_count: usize,
59
60 // ===== Coming Week =====
61 /// Count of tasks due in the coming week
62 pub tasks_due_next_week_count: usize,
63 /// Tasks due in the coming week
64 pub tasks_due_next_week: Vec<Task>,
65 /// Count of overdue tasks (from before this week)
66 pub tasks_already_overdue_count: usize,
67
68 // ===== Focus =====
69 /// Tasks currently marked as focus for the week
70 pub focused_tasks: Vec<Task>,
71 /// High-priority pending tasks available for focus selection
72 pub available_for_focus: Vec<Task>,
73
74 // ===== Derived Projects =====
75 /// Projects that have focused tasks (computed from focused_tasks)
76 pub focused_projects: Vec<ProjectSummary>,
77
78 // ===== Project Health =====
79 /// Health status for all projects
80 pub project_health: Vec<ProjectHealth>,
81
82 // ===== Nudge =====
83 /// Days marked as vacation for the coming week (0=Mon ... 6=Sun)
84 pub vacation_days: Vec<u8>,
85
86 /// Whether to show the weekly review nudge (Monday, not completed)
87 pub show_nudge: bool,
88 }
89
90 /// Minimal event info for display in the weekly review.
91 #[derive(Debug, Clone, Serialize)]
92 #[serde(rename_all = "camelCase")]
93 pub struct EventSummary {
94 pub id: EventId,
95 pub title: String,
96 pub start_time: DateTime<Utc>,
97 /// Human-readable date/time (e.g., "Mon 10:00 AM")
98 pub formatted_time: String,
99 pub project_name: Option<String>,
100 }
101
102 /// Minimal project info for display.
103 #[derive(Debug, Clone, Serialize)]
104 #[serde(rename_all = "camelCase")]
105 pub struct ProjectSummary {
106 pub id: ProjectId,
107 pub name: String,
108 pub focused_task_count: usize,
109 }
110
111 /// Timeline data for a single day in the week-at-a-glance view.
112 #[derive(Debug, Clone, Serialize)]
113 #[serde(rename_all = "camelCase")]
114 pub struct TimelineDayData {
115 /// Date in YYYY-MM-DD format
116 pub date: String,
117 /// Short day name (Mon, Tue, etc.)
118 pub day_name: String,
119 /// Day of month (1-31)
120 pub day_number: u32,
121 /// Whether this is today
122 pub is_today: bool,
123 /// Whether this day is in the past
124 pub is_past: bool,
125 /// Number of tasks completed on this day
126 pub completed_count: i32,
127 /// Number of events on this day
128 pub event_count: i32,
129 /// Number of tasks that became overdue on this day
130 pub overdue_count: i32,
131 /// Number of tasks due on this day (future days only)
132 pub due_count: i32,
133 /// Whether this day is marked as vacation
134 pub is_vacation: bool,
135 /// Events occurring on this day (capped at 5)
136 pub events: Vec<EventSummary>,
137 }
138
139 /// Project health status for the weekly review.
140 #[derive(Debug, Clone, Serialize)]
141 #[serde(rename_all = "camelCase")]
142 pub struct ProjectHealth {
143 pub id: ProjectId,
144 pub name: String,
145 /// Number of active (pending/started) tasks
146 pub active_count: i32,
147 /// Number of overdue tasks
148 pub overdue_count: i32,
149 /// Total task count (non-deleted)
150 pub total_count: i32,
151 /// Health status: "healthy", "warning", or "danger"
152 pub status: String,
153 }
154
155 /// All data needed to compute the weekly review, pre-fetched by the command layer.
156 pub struct WeeklyReviewInput {
157 pub week_start: NaiveDate,
158 pub review: Option<WeeklyReview>,
159 pub tasks_completed: Vec<Task>,
160 pub tasks_overdue: Vec<Task>,
161 pub events_occurred: Vec<Event>,
162 pub upcoming_events: Vec<Event>,
163 pub tasks_due_next_week: Vec<Task>,
164 pub tasks_already_overdue: Vec<Task>,
165 pub all_tasks: Vec<Task>,
166 pub focused_tasks: Vec<Task>,
167 pub available_for_focus: Vec<Task>,
168 pub projects: Vec<crate::models::Project>,
169 /// User's timezone, for local-day bucketing of the timeline.
170 pub tz: Tz,
171 }
172
173 // ============ Date Helpers ============
174
175 /// Gets the Monday of the current ISO week.
176 pub fn current_week_start() -> NaiveDate {
177 let today = Utc::now().date_naive();
178 let days_from_monday = today.weekday().num_days_from_monday();
179 today - Duration::days(days_from_monday as i64)
180 }
181
182 /// Parses a "YYYY-MM-DD" string into the Monday of that ISO week.
183 /// Accepts any date in the week and snaps to its Monday, so callers don't
184 /// have to pre-compute the boundary.
185 pub fn parse_week_start(s: &str) -> Option<NaiveDate> {
186 let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
187 let days_from_monday = date.weekday().num_days_from_monday();
188 Some(date - Duration::days(days_from_monday as i64))
189 }
190
191 /// Gets the Sunday of the week starting on the given Monday.
192 pub fn week_end(week_start: NaiveDate) -> NaiveDate {
193 week_start + Duration::days(6)
194 }
195
196 /// Formats a week range for display (e.g., "Feb 10 - Feb 16").
197 pub fn format_week_display(start: NaiveDate, end: NaiveDate) -> String {
198 format!("{} - {}", start.format("%b %d"), end.format("%b %d"))
199 }
200
201 // ============ Pure Aggregation ============
202
203 /// Computes the full weekly review from pre-fetched data.
204 ///
205 /// This is a pure function — all I/O must be done before calling this.
206 pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
207 let tz = input.tz;
208 let week_start = input.week_start;
209 let week_end_date = week_end(week_start);
210 let now = Utc::now();
211 let today = now.with_timezone(&tz).date_naive();
212
213 let week_start_dt = civil_midnight_utc(week_start, tz);
214
215 let vacation_days: Vec<u8> = input.review.as_ref()
216 .map(|r| r.vacation_days.clone())
217 .unwrap_or_default();
218
219 // Pending tasks count and carried-over tasks
220 let pending_count = input.all_tasks.iter()
221 .filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started)
222 .count();
223
224 let carried_over_tasks: Vec<_> = input.all_tasks.iter()
225 .filter(|t| {
226 (t.status == TaskStatus::Pending || t.status == TaskStatus::Started)
227 && t.created_at < week_start_dt
228 })
229 .cloned()
230 .collect();
231 let carried_over_count = carried_over_tasks.len();
232
233 // Compute focused projects from focused tasks
234 let focused_projects = compute_focused_projects(&input.focused_tasks);
235
236 // Build timeline
237 let timeline_days = build_timeline_days(
238 week_start,
239 today,
240 tz,
241 &vacation_days,
242 &input.tasks_completed,
243 &input.events_occurred,
244 &input.tasks_overdue,
245 &input.tasks_due_next_week,
246 &input.upcoming_events,
247 );
248
249 // Compute project health
250 let project_health = compute_project_health(&input.projects, &input.all_tasks);
251
252 // Determine if nudge should show
253 let show_nudge = should_show_nudge(&input.review, tz);
254
255 WeeklyReviewData {
256 week_start_date: week_start.format("%Y-%m-%d").to_string(),
257 week_end_date: week_end_date.format("%Y-%m-%d").to_string(),
258 week_display: format_week_display(week_start, week_end_date),
259 is_completed: input.review.is_some(),
260 completed_at: input.review.as_ref().map(|r| r.completed_at),
261 notes: input.review.as_ref().map(|r| r.notes.clone()).unwrap_or_default(),
262
263 timeline_days,
264
265 tasks_completed_count: input.tasks_completed.len(),
266 tasks_completed: input.tasks_completed,
267 tasks_overdue_count: input.tasks_overdue.len(),
268 tasks_overdue: input.tasks_overdue,
269 events_occurred_count: input.events_occurred.len(),
270 events_occurred: input.events_occurred.iter().map(|e| event_to_summary(e, tz)).collect(),
271 tasks_pending_count: pending_count,
272 carried_over_tasks,
273 carried_over_count,
274
275 tasks_due_next_week_count: input.tasks_due_next_week.len(),
276 tasks_due_next_week: input.tasks_due_next_week,
277 tasks_already_overdue_count: input.tasks_already_overdue.len(),
278
279 focused_tasks: input.focused_tasks,
280 available_for_focus: input.available_for_focus,
281 focused_projects,
282 project_health,
283
284 vacation_days,
285 show_nudge,
286 }
287 }
288
289 /// Builds timeline data for each day of the review week (Mon–Sun).
290 #[allow(clippy::too_many_arguments)]
291 pub fn build_timeline_days(
292 week_start: NaiveDate,
293 today: NaiveDate,
294 tz: Tz,
295 vacation_days: &[u8],
296 tasks_completed: &[Task],
297 events_occurred: &[Event],
298 tasks_overdue: &[Task],
299 tasks_due_next_week: &[Task],
300 upcoming_events: &[Event],
301 ) -> Vec<TimelineDayData> {
302 let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
303
304 (0..7)
305 .map(|day_offset| {
306 let date = week_start + Duration::days(day_offset);
307 // Local-day boundaries so edge-of-day rows land on the right day.
308 let day_start = civil_midnight_utc(date, tz);
309 let next_day_start = civil_midnight_utc(date + Duration::days(1), tz);
310
311 let completed_count = tasks_completed.iter()
312 .filter(|t| {
313 t.completed_at
314 .map(|ca| ca >= day_start && ca < next_day_start)
315 .unwrap_or(false)
316 })
317 .count() as i32;
318
319 let event_count = events_occurred.iter()
320 .chain(upcoming_events.iter())
321 .filter(|e| e.start_time >= day_start && e.start_time < next_day_start)
322 .count() as i32;
323
324 let overdue_count = tasks_overdue.iter()
325 .filter(|t| t.due.map(|d| d.with_timezone(&tz).date_naive() == date).unwrap_or(false))
326 .count() as i32;
327
328 let due_count = if date > today {
329 tasks_due_next_week.iter()
330 .filter(|t| t.due.map(|d| d.with_timezone(&tz).date_naive() == date).unwrap_or(false))
331 .count() as i32
332 } else {
333 0
334 };
335
336 // Collect events for this day from both past and upcoming, capped at 5
337 let day_events: Vec<EventSummary> = events_occurred.iter()
338 .chain(upcoming_events.iter())
339 .filter(|e| e.start_time >= day_start && e.start_time < next_day_start)
340 .take(5)
341 .map(|e| event_to_summary(e, tz))
342 .collect();
343
344 TimelineDayData {
345 date: date.format("%Y-%m-%d").to_string(),
346 day_name: day_names[day_offset as usize].to_string(),
347 day_number: date.day(),
348 is_today: date == today,
349 is_past: date < today,
350 completed_count,
351 event_count,
352 overdue_count,
353 due_count,
354 is_vacation: vacation_days.contains(&(day_offset as u8)),
355 events: day_events,
356 }
357 })
358 .collect()
359 }
360
361 /// Computes project health from projects and all tasks.
362 pub fn compute_project_health(
363 projects: &[crate::models::Project],
364 all_tasks: &[Task],
365 ) -> Vec<ProjectHealth> {
366 projects.iter()
367 .filter_map(|project| {
368 let project_tasks: Vec<_> = all_tasks.iter()
369 .filter(|t| t.project_id == Some(project.id) && t.status != TaskStatus::Deleted)
370 .collect();
371
372 if project_tasks.is_empty() {
373 return None;
374 }
375
376 let active_count = project_tasks.iter()
377 .filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started)
378 .count() as i32;
379
380 let overdue_count = project_tasks.iter()
381 .filter(|t| t.is_overdue())
382 .count() as i32;
383
384 let total_count = project_tasks.len() as i32;
385
386 let status = if overdue_count >= 3 {
387 "danger"
388 } else if overdue_count > 0 {
389 "warning"
390 } else {
391 "healthy"
392 }.to_string();
393
394 Some(ProjectHealth {
395 id: project.id,
396 name: project.name.clone(),
397 active_count,
398 overdue_count,
399 total_count,
400 status,
401 })
402 })
403 .collect()
404 }
405
406 /// Groups focused tasks by project.
407 fn compute_focused_projects(focused_tasks: &[Task]) -> Vec<ProjectSummary> {
408 let mut project_focus_counts: HashMap<ProjectId, (String, usize)> = HashMap::new();
409 for task in focused_tasks {
410 if let Some(project_id) = task.project_id {
411 let project_name = task.project_name.clone().unwrap_or_else(|| "Unknown".to_string());
412 project_focus_counts.entry(project_id)
413 .and_modify(|(_, count)| *count += 1)
414 .or_insert((project_name, 1));
415 }
416 }
417 project_focus_counts.into_iter()
418 .map(|(id, (name, count))| ProjectSummary {
419 id,
420 name,
421 focused_task_count: count,
422 })
423 .collect()
424 }
425
426 /// Determines if the weekly review nudge should show (Monday, not completed).
427 pub fn should_show_nudge(review: &Option<WeeklyReview>, tz: Tz) -> bool {
428 let is_monday = Utc::now().with_timezone(&tz).weekday() == Weekday::Mon;
429 is_monday && review.is_none()
430 }
431
432 /// Formats event time for display in the user's timezone (e.g., "Mon 10:00").
433 fn format_event_time(dt: &DateTime<Utc>, tz: Tz) -> String {
434 dt.with_timezone(&tz).format("%a %H:%M").to_string()
435 }
436
437 /// Converts an Event to a minimal EventSummary.
438 fn event_to_summary(event: &Event, tz: Tz) -> EventSummary {
439 EventSummary {
440 id: event.id,
441 title: event.title.clone(),
442 start_time: event.start_time,
443 formatted_time: format_event_time(&event.start_time, tz),
444 project_name: event.project_name.clone(),
445 }
446 }
447
448 #[cfg(test)]
449 mod tests {
450 use super::*;
451 use crate::id_types::{EventId, TaskId, UserId, WeeklyReviewId};
452 use crate::models::{Priority, Recurrence};
453 use chrono::NaiveDate;
454
455 /// Creates a minimal completed task with specified created_at and completed_at.
456 fn make_completed_task(
457 created_at: DateTime<Utc>,
458 completed_at: DateTime<Utc>,
459 ) -> Task {
460 Task {
461 id: TaskId::new(),
462 project_id: None,
463 project_name: None,
464 milestone_id: None,
465 contact_id: None,
466 contact_name: None,
467 description: "test".to_string(),
468 status: TaskStatus::Completed,
469 priority: Priority::Medium,
470 due: None,
471 tags: vec![],
472 urgency: 0.0,
473 recurrence: Recurrence::None,
474 recurrence_rule: None,
475 recurrence_parent_id: None,
476 source_email_id: None,
477 snoozed_until: None,
478 waiting_for_response: false,
479 waiting_since: None,
480 expected_response_date: None,
481 scheduled_start: None,
482 scheduled_duration: None,
483 annotations: vec![],
484 subtasks: vec![],
485 status_tokens: vec![],
486 created_at,
487 completed_at: Some(completed_at),
488 is_focus: false,
489 focus_set_at: None,
490 estimated_minutes: None,
491 actual_minutes: 0,
492 active_session: None,
493 }
494 }
495
496 #[test]
497 fn test_week_end() {
498 let monday = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
499 let sunday = week_end(monday);
500 assert_eq!(sunday, NaiveDate::from_ymd_opt(2026, 2, 15).unwrap());
501 }
502
503 #[test]
504 fn test_format_week_display() {
505 let start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
506 let end = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap();
507 assert_eq!(format_week_display(start, end), "Feb 09 - Feb 15");
508 }
509
510 #[test]
511 fn test_timeline_buckets_in_local_timezone() {
512 // A task completed at 23:00 local (America/New_York, UTC-5) on Friday
513 // Feb 13 is 04:00 UTC on Saturday Feb 14. Local-day bucketing must
514 // attribute it to Friday (index 4), not Saturday (index 5).
515 let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
516 let today = NaiveDate::from_ymd_opt(2026, 2, 16).unwrap(); // next week
517 let ny: Tz = "America/New_York".parse().unwrap();
518
519 let completed_utc = NaiveDate::from_ymd_opt(2026, 2, 14)
520 .unwrap()
521 .and_hms_opt(4, 0, 0)
522 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
523 .unwrap();
524 let tasks = vec![make_completed_task(completed_utc, completed_utc)];
525
526 let local = build_timeline_days(week_start, today, ny, &[], &tasks, &[], &[], &[], &[]);
527 assert_eq!(local[4].completed_count, 1, "Friday (local) should hold the completion");
528 assert_eq!(local[5].completed_count, 0, "Saturday should be empty in local time");
529
530 // Sanity: with UTC the same row lands on Saturday, proving the tz matters.
531 let utc = build_timeline_days(week_start, today, Tz::UTC, &[], &tasks, &[], &[], &[], &[]);
532 assert_eq!(utc[5].completed_count, 1, "UTC bucketing lands on Saturday");
533 }
534
535 #[test]
536 fn test_should_show_nudge_with_review() {
537 let review = Some(WeeklyReview {
538 id: WeeklyReviewId::new(),
539 user_id: UserId::new(),
540 week_start_date: NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(),
541 notes: String::new(),
542 completed_at: Utc::now(),
543 vacation_days: vec![],
544 });
545 // Should never show nudge if review exists
546 assert!(!should_show_nudge(&review, Tz::UTC));
547 }
548
549 #[test]
550 fn test_compute_project_health_empty_projects() {
551 let projects = vec![];
552 let tasks = vec![];
553 let health = compute_project_health(&projects, &tasks);
554 assert!(health.is_empty());
555 }
556
557 #[test]
558 fn test_build_timeline_days_count() {
559 let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
560 let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap();
561 let days = build_timeline_days(week_start, today, Tz::UTC, &[], &[], &[], &[], &[], &[]);
562 assert_eq!(days.len(), 7);
563 assert_eq!(days[0].day_name, "Mon");
564 assert_eq!(days[6].day_name, "Sun");
565 assert!(!days[0].is_today);
566 assert!(days[2].is_today); // Wednesday = index 2
567 assert!(days[0].is_past);
568 assert!(days[1].is_past);
569 assert!(!days[2].is_past); // today is not past
570 }
571
572 #[test]
573 fn test_timeline_uses_completed_at_not_created_at() {
574 // Task created on Monday (Feb 9) but completed on Friday (Feb 13).
575 // The timeline should show it as completed on Friday, not Monday.
576 let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
577 let today = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(); // Saturday
578
579 let monday = week_start
580 .and_hms_opt(10, 0, 0)
581 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
582 .unwrap();
583 let friday = NaiveDate::from_ymd_opt(2026, 2, 13)
584 .unwrap()
585 .and_hms_opt(15, 0, 0)
586 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
587 .unwrap();
588
589 let task = make_completed_task(monday, friday);
590 let tasks_completed = vec![task];
591
592 let days = build_timeline_days(
593 week_start,
594 today,
595 Tz::UTC,
596 &[],
597 &tasks_completed,
598 &[],
599 &[],
600 &[],
601 &[],
602 );
603
604 // Monday (index 0) should have 0 completions (task was created Monday but not completed then)
605 assert_eq!(
606 days[0].completed_count, 0,
607 "Monday should have 0 completions (task was created Monday but completed Friday)"
608 );
609 // Friday (index 4) should have 1 completion
610 assert_eq!(
611 days[4].completed_count, 1,
612 "Friday should have 1 completion (task was completed on Friday)"
613 );
614 }
615
616 #[test]
617 fn test_timeline_task_without_completed_at_not_counted() {
618 // Edge case: a completed task with no completed_at timestamp should not count.
619 let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
620 let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap();
621
622 let monday = week_start
623 .and_hms_opt(10, 0, 0)
624 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
625 .unwrap();
626
627 let mut task = make_completed_task(monday, monday);
628 task.completed_at = None; // No completed_at timestamp
629
630 let tasks_completed = vec![task];
631 let days = build_timeline_days(
632 week_start,
633 today,
634 Tz::UTC,
635 &[],
636 &tasks_completed,
637 &[],
638 &[],
639 &[],
640 &[],
641 );
642
643 // No day should count this task
644 for day in &days {
645 assert_eq!(
646 day.completed_count, 0,
647 "Day {} should have 0 completions for task without completed_at",
648 day.day_name
649 );
650 }
651 }
652
653 /// Creates a minimal event at the given time.
654 fn make_event(title: &str, start_time: DateTime<Utc>) -> Event {
655 Event {
656 id: EventId::new(),
657 user_id: None,
658 project_id: None,
659 project_name: None,
660 contact_id: None,
661 contact_name: None,
662 title: title.to_string(),
663 description: String::new(),
664 start_time,
665 end_time: None,
666 location: None,
667 linked_task_id: None,
668 recurrence: Recurrence::None,
669 recurrence_rule: None,
670 is_recurring_instance: false,
671 recurrence_parent_id: None,
672 block_type: None,
673 external_id: None,
674 external_source: None,
675 is_read_only: false,
676 snoozed_until: None,
677 reminder_offsets_seconds: Vec::new(),
678 }
679 }
680
681 #[test]
682 fn test_build_timeline_days_with_events() {
683 let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
684 let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); // Wednesday
685
686 // Past event on Monday
687 let mon_10am = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap()
688 .and_hms_opt(10, 0, 0)
689 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
690 .unwrap();
691 let mon_event = make_event("Monday standup", mon_10am);
692
693 // Future event on Thursday
694 let thu_14 = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap()
695 .and_hms_opt(14, 0, 0)
696 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
697 .unwrap();
698 let thu_event = make_event("Thursday meeting", thu_14);
699
700 // Two events on Friday
701 let fri_9 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap()
702 .and_hms_opt(9, 0, 0)
703 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
704 .unwrap();
705 let fri_15 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap()
706 .and_hms_opt(15, 0, 0)
707 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
708 .unwrap();
709 let fri_event1 = make_event("Friday morning", fri_9);
710 let fri_event2 = make_event("Friday afternoon", fri_15);
711
712 let events_occurred = vec![mon_event];
713 let upcoming_events = vec![thu_event, fri_event1, fri_event2];
714
715 let days = build_timeline_days(
716 week_start,
717 today,
718 Tz::UTC,
719 &[],
720 &[],
721 &events_occurred,
722 &[],
723 &[],
724 &upcoming_events,
725 );
726
727 // Monday (index 0): 1 event from events_occurred
728 assert_eq!(days[0].events.len(), 1, "Monday should have 1 event");
729 assert_eq!(days[0].events[0].title, "Monday standup");
730
731 // Tuesday (index 1): no events
732 assert_eq!(days[1].events.len(), 0, "Tuesday should have 0 events");
733
734 // Wednesday (index 2): no events
735 assert_eq!(days[2].events.len(), 0, "Wednesday should have 0 events");
736
737 // Thursday (index 3): 1 event from upcoming
738 assert_eq!(days[3].events.len(), 1, "Thursday should have 1 event");
739 assert_eq!(days[3].events[0].title, "Thursday meeting");
740
741 // Friday (index 4): 2 events from upcoming
742 assert_eq!(days[4].events.len(), 2, "Friday should have 2 events");
743
744 // Saturday & Sunday: no events
745 assert_eq!(days[5].events.len(), 0, "Saturday should have 0 events");
746 assert_eq!(days[6].events.len(), 0, "Sunday should have 0 events");
747 }
748 }
749