Skip to main content

max / goingson

Commands/Core: bucket review timelines in local time, cap manual time Ultra-fuzz Run #30 Phase 3 (Commands/Core -> A). - S-TZ residual: the #29 fix moved review *query windows* to local time but the core aggregation still bucketed per-day on UTC midnights and derived "today" from Utc::now(), so weekly/monthly timeline, heat-map, streak, and busiest/quietest-day misattributed edge-of-day rows for non-UTC users. Thread the user's Tz into WeeklyReviewInput/MonthlyReviewInput and bucket via a shared civil_midnight_utc helper + with_timezone(&tz).date_naive(). Also fixes the Monday nudge weekday and event time-of-day formatting to local. Test: a completion at 23:00 local lands on the local day, not the UTC day. - Minor: cap PositiveMinutes (manual time entries) at MAX_SCHEDULED_DURATION_ MINUTES so an absurd value can't inflate a task's cached actual_minutes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 03:57 UTC
Commit: 2d95f1fdfeaa48e02b9939b8fc8e08f39e805ba8
Parent: d7eb3b8
6 files changed, +97 insertions, -34 deletions
@@ -6,7 +6,23 @@
6 6 //! All functions accept an explicit `now` parameter so they are
7 7 //! deterministic and easy to test.
8 8
9 - use chrono::{DateTime, Local, Utc};
9 + use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
10 + use chrono_tz::Tz;
11 +
12 + /// Converts a calendar date's local midnight to the corresponding UTC instant.
13 + ///
14 + /// Day-bucketing (weekly/monthly review timelines, heat-maps, streaks) must use
15 + /// the user's *local* day boundaries; stamping a civil midnight as UTC
16 + /// misattributes edge-of-day rows by the zone offset. On a DST spring-forward
17 + /// gap the civil midnight doesn't exist, so fall back to treating it as UTC
18 + /// rather than panicking (mirrors `tz::local_civil_to_utc` in the app crate).
19 + pub fn civil_midnight_utc(date: NaiveDate, tz: Tz) -> DateTime<Utc> {
20 + let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
21 + tz.from_local_datetime(&naive)
22 + .earliest()
23 + .map(|dt| dt.with_timezone(&Utc))
24 + .unwrap_or_else(|| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
25 + }
10 26
11 27 /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15".
12 28 ///
@@ -23,8 +23,20 @@ pub struct PositiveMinutes(NonZeroU32);
23 23
24 24 impl PositiveMinutes {
25 25 /// Validate a raw minute count. Returns a `Validation` error for anything
26 - /// less than 1 (including negatives, which fail the `u32` conversion).
26 + /// less than 1 (including negatives, which fail the `u32` conversion) or
27 + /// greater than a single day — a manual session is one work period, and an
28 + /// unbounded value would inflate the task's cached `actual_minutes`
29 + /// arbitrarily.
27 30 pub fn try_new(minutes: i32) -> crate::Result<Self> {
31 + if minutes > crate::constants::MAX_SCHEDULED_DURATION_MINUTES {
32 + return Err(CoreError::validation(
33 + "minutes",
34 + format!(
35 + "Minutes must be at most {}",
36 + crate::constants::MAX_SCHEDULED_DURATION_MINUTES
37 + ),
38 + ));
39 + }
28 40 u32::try_from(minutes)
29 41 .ok()
30 42 .and_then(NonZeroU32::new)
@@ -4,10 +4,12 @@
4 4 //! All I/O is done by the command layer; this module only transforms
5 5 //! pre-fetched data into the final response shape.
6 6
7 - use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
7 + use chrono::{Datelike, Duration, NaiveDate, Utc};
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::ProjectId;
12 14 use crate::models::{Event, MonthlyGoal, MonthlyReflection, Task, TaskStatus};
13 15 use crate::weekly_review::{compute_project_health, ProjectHealth};
@@ -110,6 +112,8 @@ pub struct MonthlyReviewInput {
110 112 pub goals: Vec<MonthlyGoal>,
111 113 pub reflection: Option<MonthlyReflection>,
112 114 pub vacation_days: Vec<NaiveDate>,
115 + /// User's timezone, for local-day bucketing of the heat-map and streak.
116 + pub tz: Tz,
113 117 }
114 118
115 119 // ============ Date Helpers ============
@@ -153,7 +157,8 @@ pub fn parse_month(month_str: &str) -> Option<NaiveDate> {
153 157
154 158 /// Computes the full monthly review from pre-fetched data.
155 159 pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData {
156 - let today = Utc::now().date_naive();
160 + let tz = input.tz;
161 + let today = Utc::now().with_timezone(&tz).date_naive();
157 162 let month_start = input.month_start;
158 163 let month_end_date = input.month_end;
159 164 let days_in_month = (month_end_date - month_start).num_days() as u32 + 1;
@@ -166,12 +171,10 @@ pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData {
166 171 let days: Vec<MonthDayData> = (0..days_in_month)
167 172 .map(|day_offset| {
168 173 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);
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);
175 178
176 179 let completed_count = input.tasks_completed.iter()
177 180 .filter(|t| {
@@ -5,9 +5,11 @@
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,6 +166,8 @@ pub struct WeeklyReviewInput {
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,14 +204,13 @@ pub fn format_week_display(start: NaiveDate, end: NaiveDate) -> String {
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,6 +237,7 @@ pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
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,7 +250,7 @@ pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
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,7 +267,7 @@ pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
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,6 +291,7 @@ pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
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,12 +304,9 @@ pub fn build_timeline_days(
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,12 +322,12 @@ pub fn build_timeline_days(
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,7 +338,7 @@ pub fn build_timeline_days(
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,23 +424,23 @@ fn compute_focused_projects(focused_tasks: &[Task]) -> Vec<ProjectSummary> {
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,6 +507,31 @@ mod tests {
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,7 +542,7 @@ mod tests {
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,7 +557,7 @@ mod tests {
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,6 +591,7 @@ mod tests {
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,6 +630,7 @@ mod tests {
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,6 +714,7 @@ mod tests {
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,
@@ -160,6 +160,7 @@ pub async fn get_monthly_review(
160 160 goals,
161 161 reflection,
162 162 vacation_days,
163 + tz: crate::tz::system_tz(),
163 164 });
164 165
165 166 Ok(MonthlyReviewResponse::from(data))
@@ -196,6 +196,7 @@ pub async fn get_weekly_review(
196 196 focused_tasks,
197 197 available_for_focus,
198 198 projects,
199 + tz,
199 200 });
200 201
201 202 // Convert Task → TaskResponse for frontend