Skip to main content

max / goingson

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