| 5 |
5 |
|
//! pre-fetched data into the final response shape.
|
| 6 |
6 |
|
|
| 7 |
7 |
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
|
8 |
+ |
use chrono_tz::Tz;
|
| 8 |
9 |
|
use serde::Serialize;
|
| 9 |
10 |
|
use std::collections::HashMap;
|
| 10 |
11 |
|
|
|
12 |
+ |
use crate::date_utils::civil_midnight_utc;
|
| 11 |
13 |
|
use crate::id_types::{EventId, ProjectId};
|
| 12 |
14 |
|
use crate::models::{Event, Task, TaskStatus, WeeklyReview};
|
| 13 |
15 |
|
|
| 164 |
166 |
|
pub focused_tasks: Vec<Task>,
|
| 165 |
167 |
|
pub available_for_focus: Vec<Task>,
|
| 166 |
168 |
|
pub projects: Vec<crate::models::Project>,
|
|
169 |
+ |
/// User's timezone, for local-day bucketing of the timeline.
|
|
170 |
+ |
pub tz: Tz,
|
| 167 |
171 |
|
}
|
| 168 |
172 |
|
|
| 169 |
173 |
|
// ============ Date Helpers ============
|
| 200 |
204 |
|
///
|
| 201 |
205 |
|
/// This is a pure function — all I/O must be done before calling this.
|
| 202 |
206 |
|
pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
|
|
207 |
+ |
let tz = input.tz;
|
| 203 |
208 |
|
let week_start = input.week_start;
|
| 204 |
209 |
|
let week_end_date = week_end(week_start);
|
| 205 |
210 |
|
let now = Utc::now();
|
| 206 |
|
- |
let today = now.date_naive();
|
|
211 |
+ |
let today = now.with_timezone(&tz).date_naive();
|
| 207 |
212 |
|
|
| 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);
|
|
213 |
+ |
let week_start_dt = civil_midnight_utc(week_start, tz);
|
| 211 |
214 |
|
|
| 212 |
215 |
|
let vacation_days: Vec<u8> = input.review.as_ref()
|
| 213 |
216 |
|
.map(|r| r.vacation_days.clone())
|
| 234 |
237 |
|
let timeline_days = build_timeline_days(
|
| 235 |
238 |
|
week_start,
|
| 236 |
239 |
|
today,
|
|
240 |
+ |
tz,
|
| 237 |
241 |
|
&vacation_days,
|
| 238 |
242 |
|
&input.tasks_completed,
|
| 239 |
243 |
|
&input.events_occurred,
|
| 246 |
250 |
|
let project_health = compute_project_health(&input.projects, &input.all_tasks);
|
| 247 |
251 |
|
|
| 248 |
252 |
|
// Determine if nudge should show
|
| 249 |
|
- |
let show_nudge = should_show_nudge(&input.review);
|
|
253 |
+ |
let show_nudge = should_show_nudge(&input.review, tz);
|
| 250 |
254 |
|
|
| 251 |
255 |
|
WeeklyReviewData {
|
| 252 |
256 |
|
week_start_date: week_start.format("%Y-%m-%d").to_string(),
|
| 263 |
267 |
|
tasks_overdue_count: input.tasks_overdue.len(),
|
| 264 |
268 |
|
tasks_overdue: input.tasks_overdue,
|
| 265 |
269 |
|
events_occurred_count: input.events_occurred.len(),
|
| 266 |
|
- |
events_occurred: input.events_occurred.iter().map(event_to_summary).collect(),
|
|
270 |
+ |
events_occurred: input.events_occurred.iter().map(|e| event_to_summary(e, tz)).collect(),
|
| 267 |
271 |
|
tasks_pending_count: pending_count,
|
| 268 |
272 |
|
carried_over_tasks,
|
| 269 |
273 |
|
carried_over_count,
|
| 287 |
291 |
|
pub fn build_timeline_days(
|
| 288 |
292 |
|
week_start: NaiveDate,
|
| 289 |
293 |
|
today: NaiveDate,
|
|
294 |
+ |
tz: Tz,
|
| 290 |
295 |
|
vacation_days: &[u8],
|
| 291 |
296 |
|
tasks_completed: &[Task],
|
| 292 |
297 |
|
events_occurred: &[Event],
|
| 299 |
304 |
|
(0..7)
|
| 300 |
305 |
|
.map(|day_offset| {
|
| 301 |
306 |
|
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);
|
|
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);
|
| 308 |
310 |
|
|
| 309 |
311 |
|
let completed_count = tasks_completed.iter()
|
| 310 |
312 |
|
.filter(|t| {
|
| 320 |
322 |
|
.count() as i32;
|
| 321 |
323 |
|
|
| 322 |
324 |
|
let overdue_count = tasks_overdue.iter()
|
| 323 |
|
- |
.filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false))
|
|
325 |
+ |
.filter(|t| t.due.map(|d| d.with_timezone(&tz).date_naive() == date).unwrap_or(false))
|
| 324 |
326 |
|
.count() as i32;
|
| 325 |
327 |
|
|
| 326 |
328 |
|
let due_count = if date > today {
|
| 327 |
329 |
|
tasks_due_next_week.iter()
|
| 328 |
|
- |
.filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false))
|
|
330 |
+ |
.filter(|t| t.due.map(|d| d.with_timezone(&tz).date_naive() == date).unwrap_or(false))
|
| 329 |
331 |
|
.count() as i32
|
| 330 |
332 |
|
} else {
|
| 331 |
333 |
|
0
|
| 336 |
338 |
|
.chain(upcoming_events.iter())
|
| 337 |
339 |
|
.filter(|e| e.start_time >= day_start && e.start_time < next_day_start)
|
| 338 |
340 |
|
.take(5)
|
| 339 |
|
- |
.map(event_to_summary)
|
|
341 |
+ |
.map(|e| event_to_summary(e, tz))
|
| 340 |
342 |
|
.collect();
|
| 341 |
343 |
|
|
| 342 |
344 |
|
TimelineDayData {
|
| 422 |
424 |
|
}
|
| 423 |
425 |
|
|
| 424 |
426 |
|
/// 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 |
+ |
pub fn should_show_nudge(review: &Option<WeeklyReview>, tz: Tz) -> bool {
|
|
428 |
+ |
let is_monday = Utc::now().with_timezone(&tz).weekday() == Weekday::Mon;
|
| 427 |
429 |
|
is_monday && review.is_none()
|
| 428 |
430 |
|
}
|
| 429 |
431 |
|
|
| 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()
|
|
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()
|
| 433 |
435 |
|
}
|
| 434 |
436 |
|
|
| 435 |
437 |
|
/// Converts an Event to a minimal EventSummary.
|
| 436 |
|
- |
fn event_to_summary(event: &Event) -> EventSummary {
|
|
438 |
+ |
fn event_to_summary(event: &Event, tz: Tz) -> EventSummary {
|
| 437 |
439 |
|
EventSummary {
|
| 438 |
440 |
|
id: event.id,
|
| 439 |
441 |
|
title: event.title.clone(),
|
| 440 |
442 |
|
start_time: event.start_time,
|
| 441 |
|
- |
formatted_time: format_event_time(&event.start_time),
|
|
443 |
+ |
formatted_time: format_event_time(&event.start_time, tz),
|
| 442 |
444 |
|
project_name: event.project_name.clone(),
|
| 443 |
445 |
|
}
|
| 444 |
446 |
|
}
|
| 505 |
507 |
|
}
|
| 506 |
508 |
|
|
| 507 |
509 |
|
#[test]
|
|
510 |
+ |
fn test_timeline_buckets_in_local_timezone() {
|
|
511 |
+ |
// A task completed at 23:00 local (America/New_York, UTC-5) on Friday
|
|
512 |
+ |
// Feb 13 is 04:00 UTC on Saturday Feb 14. Local-day bucketing must
|
|
513 |
+ |
// attribute it to Friday (index 4), not Saturday (index 5).
|
|
514 |
+ |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday
|
|
515 |
+ |
let today = NaiveDate::from_ymd_opt(2026, 2, 16).unwrap(); // next week
|
|
516 |
+ |
let ny: Tz = "America/New_York".parse().unwrap();
|
|
517 |
+ |
|
|
518 |
+ |
let completed_utc = NaiveDate::from_ymd_opt(2026, 2, 14)
|
|
519 |
+ |
.unwrap()
|
|
520 |
+ |
.and_hms_opt(4, 0, 0)
|
|
521 |
+ |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
|
|
522 |
+ |
.unwrap();
|
|
523 |
+ |
let tasks = vec![make_completed_task(completed_utc, completed_utc)];
|
|
524 |
+ |
|
|
525 |
+ |
let local = build_timeline_days(week_start, today, ny, &[], &tasks, &[], &[], &[], &[]);
|
|
526 |
+ |
assert_eq!(local[4].completed_count, 1, "Friday (local) should hold the completion");
|
|
527 |
+ |
assert_eq!(local[5].completed_count, 0, "Saturday should be empty in local time");
|
|
528 |
+ |
|
|
529 |
+ |
// Sanity: with UTC the same row lands on Saturday, proving the tz matters.
|
|
530 |
+ |
let utc = build_timeline_days(week_start, today, Tz::UTC, &[], &tasks, &[], &[], &[], &[]);
|
|
531 |
+ |
assert_eq!(utc[5].completed_count, 1, "UTC bucketing lands on Saturday");
|
|
532 |
+ |
}
|
|
533 |
+ |
|
|
534 |
+ |
#[test]
|
| 508 |
535 |
|
fn test_should_show_nudge_with_review() {
|
| 509 |
536 |
|
let review = Some(WeeklyReview {
|
| 510 |
537 |
|
id: WeeklyReviewId::new(),
|
| 515 |
542 |
|
vacation_days: vec![],
|
| 516 |
543 |
|
});
|
| 517 |
544 |
|
// Should never show nudge if review exists
|
| 518 |
|
- |
assert!(!should_show_nudge(&review));
|
|
545 |
+ |
assert!(!should_show_nudge(&review, Tz::UTC));
|
| 519 |
546 |
|
}
|
| 520 |
547 |
|
|
| 521 |
548 |
|
#[test]
|
| 530 |
557 |
|
fn test_build_timeline_days_count() {
|
| 531 |
558 |
|
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap();
|
| 532 |
559 |
|
let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap();
|
| 533 |
|
- |
let days = build_timeline_days(week_start, today, &[], &[], &[], &[], &[], &[]);
|
|
560 |
+ |
let days = build_timeline_days(week_start, today, Tz::UTC, &[], &[], &[], &[], &[], &[]);
|
| 534 |
561 |
|
assert_eq!(days.len(), 7);
|
| 535 |
562 |
|
assert_eq!(days[0].day_name, "Mon");
|
| 536 |
563 |
|
assert_eq!(days[6].day_name, "Sun");
|
| 564 |
591 |
|
let days = build_timeline_days(
|
| 565 |
592 |
|
week_start,
|
| 566 |
593 |
|
today,
|
|
594 |
+ |
Tz::UTC,
|
| 567 |
595 |
|
&[],
|
| 568 |
596 |
|
&tasks_completed,
|
| 569 |
597 |
|
&[],
|
| 602 |
630 |
|
let days = build_timeline_days(
|
| 603 |
631 |
|
week_start,
|
| 604 |
632 |
|
today,
|
|
633 |
+ |
Tz::UTC,
|
| 605 |
634 |
|
&[],
|
| 606 |
635 |
|
&tasks_completed,
|
| 607 |
636 |
|
&[],
|
| 685 |
714 |
|
let days = build_timeline_days(
|
| 686 |
715 |
|
week_start,
|
| 687 |
716 |
|
today,
|
|
717 |
+ |
Tz::UTC,
|
| 688 |
718 |
|
&[],
|
| 689 |
719 |
|
&[],
|
| 690 |
720 |
|
&events_occurred,
|