max / goingson
7 files changed,
+226 insertions,
-55 deletions
| @@ -77,7 +77,7 @@ pub use models::{ | |||
| 77 | 77 | TimeSummaryPanel, TimeSummaryProject, TimeTrackingSummary, roll_up_time_summary, | |
| 78 | 78 | UpdateEvent, UpdateProject, UpdateTask, User, | |
| 79 | 79 | ViewFilters, ViewType, WeeklyReview, | |
| 80 | - | format_file_size, mime_from_extension, | |
| 80 | + | format_file_size, mime_from_extension, snap_all_day_span, | |
| 81 | 81 | }; | |
| 82 | 82 | pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult}; | |
| 83 | 83 | pub use date_parser::parse_natural_date; |
| @@ -172,6 +172,52 @@ impl Event { | |||
| 172 | 172 | } | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | + | /// Snap a start / optional-end instant to the canonical all-day span in `tz`. | |
| 176 | + | /// | |
| 177 | + | /// The start is pulled back to local midnight of its own day. The end becomes the | |
| 178 | + | /// *exclusive* local-midnight boundary after the last covered day: | |
| 179 | + | /// - no end given → the day after the start (a single-day span); | |
| 180 | + | /// - an end already at local midnight is treated as already-exclusive and kept | |
| 181 | + | /// as-is (so re-snapping a stored all-day span is idempotent — it does not grow | |
| 182 | + | /// by a day on every edit); | |
| 183 | + | /// - a mid-day end covers the whole of its day, so it rounds up to the next midnight. | |
| 184 | + | /// | |
| 185 | + | /// Both bounds are returned as UTC instants — the shape [`Event::is_all_day_in`] | |
| 186 | + | /// detects, so a snapped event round-trips as all-day. | |
| 187 | + | /// | |
| 188 | + | /// On a DST spring-forward gap the civil midnight doesn't exist; it falls back to | |
| 189 | + | /// treating the naive value as UTC rather than panicking (mirrors the app's | |
| 190 | + | /// `tz::local_civil_to_utc`). | |
| 191 | + | pub fn snap_all_day_span<Tz: TimeZone>( | |
| 192 | + | start: DateTime<Utc>, | |
| 193 | + | end: Option<DateTime<Utc>>, | |
| 194 | + | tz: &Tz, | |
| 195 | + | ) -> (DateTime<Utc>, DateTime<Utc>) { | |
| 196 | + | let start_day = start.with_timezone(tz).date_naive(); | |
| 197 | + | let end_exclusive = match end { | |
| 198 | + | None => start_day.succ_opt().unwrap_or(start_day), | |
| 199 | + | Some(e) => { | |
| 200 | + | let local_end = e.with_timezone(tz); | |
| 201 | + | let end_day = local_end.date_naive(); | |
| 202 | + | if local_end.time() == chrono::NaiveTime::MIN { | |
| 203 | + | end_day // already the exclusive midnight boundary | |
| 204 | + | } else { | |
| 205 | + | end_day.succ_opt().unwrap_or(end_day) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | }; | |
| 209 | + | (local_midnight_utc(start_day, tz), local_midnight_utc(end_exclusive, tz)) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// Convert a calendar date's local midnight in `tz` to the corresponding UTC instant. | |
| 213 | + | fn local_midnight_utc<Tz: TimeZone>(date: chrono::NaiveDate, tz: &Tz) -> DateTime<Utc> { | |
| 214 | + | let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid"); | |
| 215 | + | tz.from_local_datetime(&naive) | |
| 216 | + | .earliest() | |
| 217 | + | .map(|dt| dt.with_timezone(&Utc)) | |
| 218 | + | .unwrap_or_else(|| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)) | |
| 219 | + | } | |
| 220 | + | ||
| 175 | 221 | // ============ Event DTOs ============ | |
| 176 | 222 | ||
| 177 | 223 | /// Data for creating a new event. | |
| @@ -439,4 +485,44 @@ mod is_all_day_tests { | |||
| 439 | 485 | let event = event_at(start, None); | |
| 440 | 486 | assert!(!event.is_all_day_in(&Utc)); | |
| 441 | 487 | } | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn snap_single_day_round_trips_as_all_day() { | |
| 491 | + | // A mid-afternoon start with no end snaps to a single midnight-to-midnight day. | |
| 492 | + | let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap(); | |
| 493 | + | let (s, e) = snap_all_day_span(start, None, &Utc); | |
| 494 | + | assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap()); | |
| 495 | + | assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap()); | |
| 496 | + | assert!(event_at(s, Some(e)).is_all_day_in(&Utc)); | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | #[test] | |
| 500 | + | fn snap_multi_day_pushes_end_to_day_after() { | |
| 501 | + | // Start on the 4th, end mid-day on the 6th → 4th 00:00 .. 7th 00:00 (inclusive of the 6th). | |
| 502 | + | let start = Utc.with_ymd_and_hms(2026, 7, 4, 9, 0, 0).unwrap(); | |
| 503 | + | let end = Utc.with_ymd_and_hms(2026, 7, 6, 15, 0, 0).unwrap(); | |
| 504 | + | let (s, e) = snap_all_day_span(start, Some(end), &Utc); | |
| 505 | + | assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap()); | |
| 506 | + | assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 7, 0, 0, 0).unwrap()); | |
| 507 | + | assert!(event_at(s, Some(e)).is_all_day_in(&Utc)); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | #[test] | |
| 511 | + | fn snap_is_idempotent() { | |
| 512 | + | let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap(); | |
| 513 | + | let (s1, e1) = snap_all_day_span(start, None, &Utc); | |
| 514 | + | let (s2, e2) = snap_all_day_span(s1, Some(e1), &Utc); | |
| 515 | + | assert_eq!((s1, e1), (s2, e2)); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | #[test] | |
| 519 | + | fn snap_keeps_midnight_end_exclusive_no_growth() { | |
| 520 | + | // Editing an existing single-day all-day event (stored 00:00..next-00:00) | |
| 521 | + | // must not grow it by a day — the midnight end is already exclusive. | |
| 522 | + | let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); | |
| 523 | + | let end = Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap(); | |
| 524 | + | let (s, e) = snap_all_day_span(start, Some(end), &Utc); | |
| 525 | + | assert_eq!(s, start); | |
| 526 | + | assert_eq!(e, end); | |
| 527 | + | } | |
| 442 | 528 | } |
| @@ -86,7 +86,7 @@ fi | |||
| 86 | 86 | # prints the new number on progress). When it reaches 0 and the two inline | |
| 87 | 87 | # <script> blocks (index.html, compose.html) are externalized, drop | |
| 88 | 88 | # 'unsafe-inline' from the CSP. Tests and minified bundles are excluded. | |
| 89 | - | INLINE_HANDLER_BUDGET=391 | |
| 89 | + | INLINE_HANDLER_BUDGET=389 | |
| 90 | 90 | inline_count=$(grep -rlE "\bon[a-z]+=[\"']" "$FRONTEND" --include='*.html' --include='*.js' 2>/dev/null \ | |
| 91 | 91 | | grep -v '/tests/' | grep -v '\.min\.' \ | |
| 92 | 92 | | tr '\n' '\0' | xargs -0 grep -oE "\bon[a-z]+=[\"']" 2>/dev/null | wc -l | tr -d ' ') |
| @@ -523,43 +523,29 @@ | |||
| 523 | 523 | * @param {Object} data - Form data with title, description, start_time, end_time, location, etc. | |
| 524 | 524 | */ | |
| 525 | 525 | /** | |
| 526 | - | * Phase 7 Tier 5 — snap start/end to midnight pair when "All day" is set. | |
| 527 | - | * The backend doesn't have a dedicated all-day flag, so we author the | |
| 528 | - | * canonical 00:00 → next-day-00:00 shape the calendar renderer detects. | |
| 526 | + | * Convert the form's local datetime-local strings to UTC ISO instants. | |
| 527 | + | * All-day canonicalization (snapping to the local midnight-to-midnight span) | |
| 528 | + | * is done in Rust: we pass the raw instants plus `isAllDay` and the | |
| 529 | + | * create/update command authors the canonical shape. | |
| 529 | 530 | */ | |
| 530 | - | function _normalizeForAllDay(data) { | |
| 531 | - | if (!data.is_all_day) return { startTime: new Date(data.start_time).toISOString(), | |
| 532 | - | endTime: data.end_time ? new Date(data.end_time).toISOString() : null }; | |
| 533 | - | const start = new Date(data.start_time); | |
| 534 | - | if (isNaN(start.getTime())) { | |
| 535 | - | return { startTime: new Date(data.start_time).toISOString(), | |
| 536 | - | endTime: data.end_time ? new Date(data.end_time).toISOString() : null }; | |
| 537 | - | } | |
| 538 | - | const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0); | |
| 539 | - | // End: if user gave an end date, snap to midnight after that day; else single-day event. | |
| 540 | - | let endDay; | |
| 541 | - | if (data.end_time) { | |
| 542 | - | const end = new Date(data.end_time); | |
| 543 | - | if (!isNaN(end.getTime())) { | |
| 544 | - | endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1, 0, 0, 0, 0); | |
| 545 | - | } | |
| 546 | - | } | |
| 547 | - | if (!endDay) { | |
| 548 | - | endDay = new Date(startDay.getFullYear(), startDay.getMonth(), startDay.getDate() + 1, 0, 0, 0, 0); | |
| 549 | - | } | |
| 550 | - | return { startTime: startDay.toISOString(), endTime: endDay.toISOString() }; | |
| 531 | + | function _eventTimes(data) { | |
| 532 | + | return { | |
| 533 | + | startTime: new Date(data.start_time).toISOString(), | |
| 534 | + | endTime: data.end_time ? new Date(data.end_time).toISOString() : null, | |
| 535 | + | }; | |
| 551 | 536 | } | |
| 552 | 537 | ||
| 553 | 538 | async function create(data) { | |
| 554 | 539 | const form = document.querySelector('.modal-content form'); | |
| 555 | 540 | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; | |
| 556 | - | const { startTime, endTime } = _normalizeForAllDay(data); | |
| 541 | + | const { startTime, endTime } = _eventTimes(data); | |
| 557 | 542 | const input = { | |
| 558 | 543 | title: data.title, | |
| 559 | 544 | description: data.description || '', | |
| 560 | 545 | projectId: data.project_id || null, | |
| 561 | 546 | startTime, | |
| 562 | 547 | endTime, | |
| 548 | + | isAllDay: !!data.is_all_day, | |
| 563 | 549 | location: data.location || null, | |
| 564 | 550 | recurrence: data.recurrence || 'None', | |
| 565 | 551 | recurrenceRule, | |
| @@ -746,12 +732,13 @@ | |||
| 746 | 732 | async function update(id, data) { | |
| 747 | 733 | const form = document.querySelector('.modal-content form'); | |
| 748 | 734 | const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; | |
| 749 | - | const { startTime, endTime } = _normalizeForAllDay(data); | |
| 735 | + | const { startTime, endTime } = _eventTimes(data); | |
| 750 | 736 | const input = { | |
| 751 | 737 | title: data.title, | |
| 752 | 738 | description: data.description || '', | |
| 753 | 739 | startTime, | |
| 754 | 740 | endTime, | |
| 741 | + | isAllDay: !!data.is_all_day, | |
| 755 | 742 | location: data.location || null, | |
| 756 | 743 | recurrence: data.recurrence || 'None', | |
| 757 | 744 | recurrenceRule, |
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | ||
| 13 | 13 | let currentTaskId = null; | |
| 14 | 14 | let heatmapMonth = null; // Date object for displayed month | |
| 15 | + | let heatmapBuckets = []; // Rust-aggregated {date, count} per local day | |
| 15 | 16 | ||
| 16 | 17 | // ============ Open / Close ============ | |
| 17 | 18 | ||
| @@ -159,6 +160,7 @@ | |||
| 159 | 160 | /** Build the body HTML once; drawer and legacy view share output. */ | |
| 160 | 161 | function buildOverviewHtml(data) { | |
| 161 | 162 | const t = data.task; | |
| 163 | + | heatmapBuckets = data.completionBuckets || []; | |
| 162 | 164 | let html = ''; | |
| 163 | 165 | if (data.recurrenceChain.length > 0 && data.streak) { | |
| 164 | 166 | html += renderHabitSection(data); | |
| @@ -193,7 +195,7 @@ | |||
| 193 | 195 | html += `<span id="task-heatmap-month-label">${formatMonthLabel(heatmapMonth)}</span>`; | |
| 194 | 196 | html += `<button class="btn btn-sm btn-secondary" onclick="GoingsOn.taskOverview.nextMonth()">▶</button>`; | |
| 195 | 197 | html += '</div>'; | |
| 196 | - | html += `<div id="task-heatmap-container">${renderHeatmap(data.recurrenceChain)}</div>`; | |
| 198 | + | html += `<div id="task-heatmap-container">${renderHeatmap()}</div>`; | |
| 197 | 199 | ||
| 198 | 200 | // Recent completions list | |
| 199 | 201 | const completed = data.recurrenceChain | |
| @@ -220,7 +222,7 @@ | |||
| 220 | 222 | ||
| 221 | 223 | // ============ Heatmap ============ | |
| 222 | 224 | ||
| 223 | - | function renderHeatmap(chain) { | |
| 225 | + | function renderHeatmap() { | |
| 224 | 226 | const year = heatmapMonth.getFullYear(); | |
| 225 | 227 | const month = heatmapMonth.getMonth(); | |
| 226 | 228 | const daysInMonth = new Date(year, month + 1, 0).getDate(); | |
| @@ -228,14 +230,11 @@ | |||
| 228 | 230 | // Monday = 0, Sunday = 6 | |
| 229 | 231 | const firstDayOffset = (firstDay.getDay() + 6) % 7; | |
| 230 | 232 | ||
| 231 | - | // Build completion map: date string -> count | |
| 233 | + | // Completion counts per local day are pre-aggregated by Rust | |
| 234 | + | // (get_task_overview -> completionBuckets); JS only lays out the grid. | |
| 232 | 235 | const completionMap = {}; | |
| 233 | - | for (const inst of chain) { | |
| 234 | - | if (inst.completedAt) { | |
| 235 | - | const d = new Date(inst.completedAt); | |
| 236 | - | const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; | |
| 237 | - | completionMap[key] = (completionMap[key] || 0) + 1; | |
| 238 | - | } | |
| 236 | + | for (const b of heatmapBuckets) { | |
| 237 | + | completionMap[b.date] = b.count; | |
| 239 | 238 | } | |
| 240 | 239 | ||
| 241 | 240 | const today = new Date(); | |
| @@ -295,18 +294,14 @@ | |||
| 295 | 294 | refreshHeatmap(); | |
| 296 | 295 | } | |
| 297 | 296 | ||
| 298 | - | async function refreshHeatmap() { | |
| 297 | + | function refreshHeatmap() { | |
| 299 | 298 | const label = document.getElementById('task-heatmap-month-label'); | |
| 300 | 299 | if (label) label.textContent = formatMonthLabel(heatmapMonth); | |
| 301 | 300 | ||
| 302 | - | if (!currentTaskId) return; | |
| 303 | - | try { | |
| 304 | - | const data = await GoingsOn.api.tasks.getOverview(currentTaskId); | |
| 305 | - | const container = document.getElementById('task-heatmap-container'); | |
| 306 | - | if (container) container.innerHTML = renderHeatmap(data.recurrenceChain); | |
| 307 | - | } catch (err) { | |
| 308 | - | console.error('Failed to refresh heatmap:', err); | |
| 309 | - | } | |
| 301 | + | // Buckets cover the whole chain, so month navigation just re-lays out | |
| 302 | + | // the cached data — no round-trip to Rust. | |
| 303 | + | const container = document.getElementById('task-heatmap-container'); | |
| 304 | + | if (container) container.innerHTML = renderHeatmap(); | |
| 310 | 305 | } | |
| 311 | 306 | ||
| 312 | 307 | function formatMonthLabel(date) { | |
| @@ -361,7 +356,7 @@ | |||
| 361 | 356 | html += `<h3 class="task-overview-section-title">Subtasks <span class="task-overview-count">${completed}/${total}</span></h3>`; | |
| 362 | 357 | ||
| 363 | 358 | if (total > 0) { | |
| 364 | - | const pct = Math.round((completed / total) * 100); | |
| 359 | + | const pct = t.subtaskProgress ?? 0; // pre-computed in Rust (TaskResponse.subtaskProgress) | |
| 365 | 360 | html += `<div class="progress-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>`; | |
| 366 | 361 | } | |
| 367 | 362 | ||
| @@ -421,7 +416,7 @@ | |||
| 421 | 416 | html += `<h3 class="task-overview-section-title">Time Tracking <span class="task-overview-count">${esc(label)}</span></h3>`; | |
| 422 | 417 | ||
| 423 | 418 | if (t.estimatedMinutes && t.estimatedMinutes > 0) { | |
| 424 | - | const pct = Math.min(Math.round((t.actualMinutes / t.estimatedMinutes) * 100), 100); | |
| 419 | + | const pct = t.timeProgress ?? 0; // pre-computed in Rust (TaskResponse.timeProgress) | |
| 425 | 420 | const overClass = t.isOverEstimate ? ' over-estimate' : ''; | |
| 426 | 421 | html += `<div class="progress-bar${overClass}"><div class="progress-fill" style="width: ${pct}%"></div></div>`; | |
| 427 | 422 | } |
| @@ -46,6 +46,24 @@ pub struct EventInput { | |||
| 46 | 46 | /// Seconds-before-start_time to fire reminders. Empty / omitted = none. | |
| 47 | 47 | #[serde(default)] | |
| 48 | 48 | pub reminder_offsets_seconds: Vec<i64>, | |
| 49 | + | /// When true, the command snaps `start_time`/`end_time` to the canonical | |
| 50 | + | /// local midnight-to-midnight all-day span before persisting. | |
| 51 | + | #[serde(default)] | |
| 52 | + | pub is_all_day: bool, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Resolve an event's start/end instants, applying all-day snapping when requested. | |
| 56 | + | /// | |
| 57 | + | /// All-day canonicalization lives here (not in JS) so the "starts at local | |
| 58 | + | /// midnight, spans whole days" shape that [`Event::is_all_day_in`] detects is | |
| 59 | + | /// authored server-side in the user's system zone. | |
| 60 | + | fn resolve_event_span(input: &EventInput) -> (DateTime<Utc>, Option<DateTime<Utc>>) { | |
| 61 | + | if input.is_all_day { | |
| 62 | + | let (start, end) = goingson_core::snap_all_day_span(input.start_time, input.end_time, &Local); | |
| 63 | + | (start, Some(end)) | |
| 64 | + | } else { | |
| 65 | + | (input.start_time, input.end_time) | |
| 66 | + | } | |
| 49 | 67 | } | |
| 50 | 68 | ||
| 51 | 69 | #[derive(Debug, Serialize)] | |
| @@ -334,9 +352,11 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) -> | |||
| 334 | 352 | return Err(ApiError::validation("title", "Title is required")); | |
| 335 | 353 | } | |
| 336 | 354 | ||
| 355 | + | let (start_time, end_time) = resolve_event_span(&input); | |
| 356 | + | ||
| 337 | 357 | // Validate end_time > start_time if end_time is provided | |
| 338 | - | if let Some(end_time) = input.end_time | |
| 339 | - | && end_time <= input.start_time { | |
| 358 | + | if let Some(end_time) = end_time | |
| 359 | + | && end_time <= start_time { | |
| 340 | 360 | return Err(ApiError::validation("endTime", "End time must be after start time")); | |
| 341 | 361 | } | |
| 342 | 362 | ||
| @@ -348,8 +368,8 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) -> | |||
| 348 | 368 | project_id: input.project_id, | |
| 349 | 369 | title: input.title, | |
| 350 | 370 | description: input.description.unwrap_or_default(), | |
| 351 | - | start_time: input.start_time, | |
| 352 | - | end_time: input.end_time, | |
| 371 | + | start_time, | |
| 372 | + | end_time, | |
| 353 | 373 | location: input.location, | |
| 354 | 374 | linked_task_id: None, | |
| 355 | 375 | recurrence, | |
| @@ -390,12 +410,14 @@ pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: E | |||
| 390 | 410 | None => existing.block_type, | |
| 391 | 411 | }; | |
| 392 | 412 | ||
| 413 | + | let (start_time, end_time) = resolve_event_span(&input); | |
| 414 | + | ||
| 393 | 415 | let update_event = UpdateEvent { | |
| 394 | 416 | project_id: input.project_id, | |
| 395 | 417 | title: input.title, | |
| 396 | 418 | description: input.description.unwrap_or_default(), | |
| 397 | - | start_time: input.start_time, | |
| 398 | - | end_time: input.end_time, | |
| 419 | + | start_time, | |
| 420 | + | end_time, | |
| 399 | 421 | location: input.location, | |
| 400 | 422 | linked_task_id: existing.linked_task_id, | |
| 401 | 423 | recurrence, |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | //! - Urgency calculation based on priority, due date, age, and tags | |
| 12 | 12 | //! - Time blocking (scheduled_start + scheduled_duration) | |
| 13 | 13 | ||
| 14 | - | use chrono::{DateTime, Utc}; | |
| 14 | + | use chrono::{DateTime, Local, Utc}; | |
| 15 | 15 | use serde::{Deserialize, Serialize}; | |
| 16 | 16 | use std::sync::Arc; | |
| 17 | 17 | use tauri::State; | |
| @@ -668,6 +668,16 @@ pub struct StreakInfo { | |||
| 668 | 668 | pub completion_rate_30d: f64, | |
| 669 | 669 | } | |
| 670 | 670 | ||
| 671 | + | /// A completion-count bucket for one local calendar day, for the heatmap. | |
| 672 | + | #[derive(Debug, Serialize)] | |
| 673 | + | #[serde(rename_all = "camelCase")] | |
| 674 | + | pub struct HeatmapBucket { | |
| 675 | + | /// Local date, `YYYY-MM-DD`. | |
| 676 | + | pub date: String, | |
| 677 | + | /// Number of chain instances completed on that day. | |
| 678 | + | pub count: u32, | |
| 679 | + | } | |
| 680 | + | ||
| 671 | 681 | /// Full task overview response. | |
| 672 | 682 | #[derive(Debug, Serialize)] | |
| 673 | 683 | #[serde(rename_all = "camelCase")] | |
| @@ -676,6 +686,9 @@ pub struct TaskOverviewResponse { | |||
| 676 | 686 | pub time_sessions: Vec<goingson_core::TimeSession>, | |
| 677 | 687 | pub recurrence_chain: Vec<RecurrenceInstance>, | |
| 678 | 688 | pub streak: Option<StreakInfo>, | |
| 689 | + | /// Completions aggregated per local day. The heatmap renders these directly | |
| 690 | + | /// instead of re-bucketing the chain in JS. | |
| 691 | + | pub completion_buckets: Vec<HeatmapBucket>, | |
| 679 | 692 | } | |
| 680 | 693 | ||
| 681 | 694 | /// Gets comprehensive task overview data. | |
| @@ -711,14 +724,34 @@ pub async fn get_task_overview( | |||
| 711 | 724 | (Vec::new(), None) | |
| 712 | 725 | }; | |
| 713 | 726 | ||
| 727 | + | let completion_buckets = compute_completion_buckets(&chain); | |
| 728 | + | ||
| 714 | 729 | Ok(TaskOverviewResponse { | |
| 715 | 730 | task: TaskResponse::from(task), | |
| 716 | 731 | time_sessions: sessions, | |
| 717 | 732 | recurrence_chain: chain, | |
| 718 | 733 | streak, | |
| 734 | + | completion_buckets, | |
| 719 | 735 | }) | |
| 720 | 736 | } | |
| 721 | 737 | ||
| 738 | + | /// Aggregate chain completions into per-local-day counts (heatmap source). | |
| 739 | + | /// | |
| 740 | + | /// Buckets by the local calendar day of `completed_at` so a completion near | |
| 741 | + | /// midnight lands on the day the user saw it happen, matching the previous | |
| 742 | + | /// JS behaviour (which read `new Date(completedAt)` in local time). | |
| 743 | + | fn compute_completion_buckets(chain: &[RecurrenceInstance]) -> Vec<HeatmapBucket> { | |
| 744 | + | use std::collections::BTreeMap; | |
| 745 | + | let mut map: BTreeMap<String, u32> = BTreeMap::new(); | |
| 746 | + | for inst in chain { | |
| 747 | + | if let Some(completed_at) = inst.completed_at { | |
| 748 | + | let key = completed_at.with_timezone(&Local).format("%Y-%m-%d").to_string(); | |
| 749 | + | *map.entry(key).or_insert(0) += 1; | |
| 750 | + | } | |
| 751 | + | } | |
| 752 | + | map.into_iter().map(|(date, count)| HeatmapBucket { date, count }).collect() | |
| 753 | + | } | |
| 754 | + | ||
| 722 | 755 | /// Compute streak stats from a recurrence chain (sorted by created_at DESC). | |
| 723 | 756 | fn compute_streak(chain: &[Task]) -> StreakInfo { | |
| 724 | 757 | let total_instances = chain.len() as u32; | |
| @@ -792,3 +825,51 @@ pub async fn list_tasks_for_project(state: State<'_, Arc<AppState>>, project_id: | |||
| 792 | 825 | tasks.sort_by(|a, b| b.urgency.partial_cmp(&a.urgency).unwrap_or(std::cmp::Ordering::Equal)); | |
| 793 | 826 | Ok(tasks.into_iter().map(TaskResponse::from).collect()) | |
| 794 | 827 | } | |
| 828 | + | ||
| 829 | + | #[cfg(test)] | |
| 830 | + | mod completion_bucket_tests { | |
| 831 | + | use super::*; | |
| 832 | + | use chrono::TimeZone; | |
| 833 | + | ||
| 834 | + | fn instance(completed_at: Option<DateTime<Utc>>) -> RecurrenceInstance { | |
| 835 | + | let now = Utc::now(); | |
| 836 | + | RecurrenceInstance { | |
| 837 | + | id: TaskId::new(), | |
| 838 | + | status: "Completed".to_string(), | |
| 839 | + | completed_at, | |
| 840 | + | due: None, | |
| 841 | + | actual_minutes: 0, | |
| 842 | + | created_at: now, | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | #[test] | |
| 847 | + | fn buckets_count_completions_per_day_and_skip_uncompleted() { | |
| 848 | + | // Two completions on the same instant, one on a well-separated day | |
| 849 | + | // (5 days apart, so the local date differs regardless of OS offset), | |
| 850 | + | // and one instance that was never completed. | |
| 851 | + | let day_a = Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap(); | |
| 852 | + | let day_b = Utc.with_ymd_and_hms(2026, 1, 20, 12, 0, 0).unwrap(); | |
| 853 | + | let chain = vec![ | |
| 854 | + | instance(Some(day_a)), | |
| 855 | + | instance(Some(day_a)), | |
| 856 | + | instance(Some(day_b)), | |
| 857 | + | instance(None), | |
| 858 | + | ]; | |
| 859 | + | ||
| 860 | + | let buckets = compute_completion_buckets(&chain); | |
| 861 | + | ||
| 862 | + | // Two distinct days, uncompleted instance excluded. | |
| 863 | + | assert_eq!(buckets.len(), 2); | |
| 864 | + | let total: u32 = buckets.iter().map(|b| b.count).sum(); | |
| 865 | + | assert_eq!(total, 3); | |
| 866 | + | let mut counts: Vec<u32> = buckets.iter().map(|b| b.count).collect(); | |
| 867 | + | counts.sort_unstable(); | |
| 868 | + | assert_eq!(counts, vec![1, 2]); | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | #[test] | |
| 872 | + | fn empty_chain_yields_no_buckets() { | |
| 873 | + | assert!(compute_completion_buckets(&[]).is_empty()); | |
| 874 | + | } | |
| 875 | + | } |