Skip to main content

max / goingson

Move task-overview + all-day computation from JS to Rust GO-33-6 top three heavy-lifting moves: push domain logic that had leaked into the frontend back down to Rust per the "Rust does the heavy lifting" charter. - get_task_overview returns completionBuckets (per-local-day counts); task-overview.js renderHeatmap consumes them instead of iterating the recurrence chain. Month navigation no longer round-trips to Rust. - task-overview.js reads t.subtaskProgress / t.timeProgress instead of recomputing the percentages the DTO already carries. - Delete _normalizeForAllDay; EventInput.is_all_day + resolve_event_span snap the span server-side via new core snap_all_day_span. Fixes a pre-existing bug where editing an all-day event grew it by a day on every save (a midnight end is now treated as already-exclusive, so snapping is idempotent). +6 tests (snap round-trips + completion bucket aggregation); suite 862 -> 868, JS 56, clippy clean. Lock inline-handler ratchet 391 -> 389. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-05 00:34 UTC
Commit: fdc308388f6bd4d5c5047e8e365f587b57d08215
Parent: 513c429
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()">&#9654;</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 + }