Skip to main content

max / goingson

Say which timer is running, and where the time went Time tracking and the focus timer write the same session, so the app could not tell you which one you had started. They are two features and the UI now says so. The Timer view names them: Track is an open-ended stopwatch, Focus is a fixed countdown that stops itself and offers a break. Both record the same time, which is exactly why the two buttons needed a sentence each. The running-timer bar carries the mode as a label and as the colour of its top edge, thickened from a hairline so that something recording your time is not as quiet as the page chrome. No animation: the bar is persistent furniture, and a pulsing one would be the loudest thing on screen all day. The report answers the two questions the data can: where the last N days went, per project, and whether the estimates were any good. Those are deliberately not summed together, since tracked time is window-scoped and estimate-versus-actual is a lifetime property of the tasks. A project with no estimates says so rather than reporting a zero, and an unestimated task is left out of the comparison entirely rather than reading as an infinite overrun.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 18:12 UTC
Signed with PGP, not checked
Commit: eb803f97b56ef6b3340e8885752e9491eebad8e4
Parent: 3a740e8
8 files changed, +636 insertions, -9 deletions
@@ -315,6 +315,7 @@
315 315 $crate::commands::list_time_sessions,
316 316 $crate::commands::log_manual_time,
317 317 $crate::commands::get_time_summary_panel,
318 + $crate::commands::get_time_report,
318 319 // Preferences
319 320 $crate::commands::get_preferences,
320 321 $crate::commands::set_update_check_on_launch,
@@ -89,11 +89,12 @@
89 89 NewMilestone, NewProblem, NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum,
90 90 PositiveMinutes, Priority, Problem, ProblemBand, ProblemStatus, Project, ProjectStatus,
91 91 ProjectType, Recurrence, RecurrenceRule, SavedView, SortDirection, SortField, StatusToken,
92 - Subtask, SyncAccount, TOKEN_KIND_COMMIT, Task, TaskFilterQuery, TaskSortColumn, TaskStatus,
93 - TimeSession, TimeSummaryPanel, TimeSummaryProject, TimeTrackingSummary, TokenState, TzKind,
94 - UpdateEmailAccount, UpdateEvent, UpdateProject, UpdateTask, User, ViewFilters, ViewType,
95 - WeeklyReview, format_file_size, mime_from_extension, roll_up_time_summary,
96 - sanitize_reminder_offsets, snap_all_day_span, split_description,
92 + Subtask, SyncAccount, TOKEN_KIND_COMMIT, Task, TaskEstimate, TaskFilterQuery, TaskSortColumn,
93 + TaskStatus, TimeReport, TimeReportProject, TimeSession, TimeSummaryPanel, TimeSummaryProject,
94 + TimeTrackingSummary, TokenState, TzKind, UpdateEmailAccount, UpdateEvent, UpdateProject,
95 + UpdateTask, User, ViewFilters, ViewType, WeeklyReview, format_file_size, mime_from_extension,
96 + roll_up_time_report, roll_up_time_summary, sanitize_reminder_offsets, snap_all_day_span,
97 + split_description,
97 98 };
98 99 pub use parser::{ParseResult, ParsedTask, parse_quick_add, parse_quick_add_with_warnings};
99 100 pub use recurrence::{
@@ -8119,6 +8119,11 @@
8119 8119
8120 8120 /* 62. Timer Widget (Floating Bottom Bar) */
8121 8121
8122 + /* The running-timer bar. The thick top edge is the prominence: something is
8123 + recording time, and a hairline border made that as quiet as the page chrome.
8124 + Its colour is the mode, so which of the two features is running reads from
8125 + across the room. No animation, per the house style; the bar is persistent
8126 + furniture and a pulsing one would be the loudest thing on screen all day. */
8122 8127 .timer-widget {
8123 8128 position: fixed;
8124 8129 bottom: 0;
@@ -8126,11 +8131,38 @@
8126 8131 right: 0;
8127 8132 z-index: 900;
8128 8133 background: var(--surface-page);
8129 - border-top: var(--border-width) solid var(--border);
8134 + border-top: calc(var(--border-width) * 3) solid var(--action);
8130 8135 box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
8131 8136 padding: var(--gap-peer) var(--gap-group);
8132 8137 }
8133 8138
8139 + .timer-widget.mode-focus {
8140 + border-top-color: var(--category-one);
8141 + }
8142 +
8143 + /* Names the feature that owns the session. Both modes write the same row, so
8144 + without this the bar cannot say whether a countdown is running. */
8145 + .timer-mode-label {
8146 + flex: none;
8147 + padding: var(--step-hair) var(--step-tight);
8148 + border-radius: var(--radius-xs);
8149 + background: var(--action);
8150 + color: var(--content-on-action);
8151 + font-size: var(--font-size-xxs);
8152 + font-weight: 700;
8153 + text-transform: uppercase;
8154 + letter-spacing: 0.05em;
8155 + white-space: nowrap;
8156 + }
8157 +
8158 + .timer-widget.mode-focus .timer-mode-label {
8159 + background: var(--category-one);
8160 + }
8161 +
8162 + .timer-widget.mode-focus .timer-elapsed {
8163 + color: var(--category-one);
8164 + }
8165 +
8134 8166 .timer-widget.hidden {
8135 8167 transform: translateY(100%);
8136 8168 pointer-events: none;
@@ -8485,6 +8517,134 @@
8485 8517 }
8486 8518
8487 8519
8520 + /* The two features, side by side and named. They share a backend session, so
8521 + the view has to draw the line the data does not. */
8522 + .timer-modes {
8523 + display: grid;
8524 + grid-template-columns: 1fr 1fr;
8525 + gap: var(--gap-group);
8526 + margin-bottom: var(--gap-section);
8527 + }
8528 +
8529 + .ui-mode-mobile .timer-modes {
8530 + grid-template-columns: 1fr;
8531 + }
8532 +
8533 + .timer-mode-card {
8534 + padding: var(--gap-group);
8535 + background: var(--surface-overlay);
8536 + border: var(--border-width) solid var(--border);
8537 + border-left-width: calc(var(--border-width) * 3);
8538 + border-radius: var(--radius-md);
8539 + }
8540 +
8541 + /* Same two colours the running-timer bar uses, so the card and the bar that
8542 + appears when you press its button are recognisably the same thing. */
8543 + .timer-mode-card.mode-track { border-left-color: var(--action); }
8544 + .timer-mode-card.mode-focus { border-left-color: var(--category-one); }
8545 +
8546 + .timer-mode-title {
8547 + margin: 0 0 var(--gap-bound);
8548 + font-family: var(--font-heading);
8549 + font-size: var(--font-size-md);
8550 + font-weight: 700;
8551 + }
8552 +
8553 + .timer-mode-desc {
8554 + margin: 0;
8555 + font-size: var(--font-size-sm);
8556 + color: var(--content-secondary);
8557 + }
8558 +
8559 + .timer-active-banner.mode-focus {
8560 + border-color: var(--category-one);
8561 + }
8562 +
8563 + /* Report: tracked time per project over a window, beside estimate vs actual.
8564 + Two questions in one table, so the bar (where the time went) and the estimate
8565 + line (were the guesses any good) are visually separate rows. */
8566 + .timer-report-header {
8567 + display: flex;
8568 + align-items: center;
8569 + justify-content: space-between;
8570 + gap: var(--gap-peer);
8571 + margin-top: var(--gap-pane);
8572 + }
8573 +
8574 + .timer-report-title {
8575 + margin: 0;
8576 + font-family: var(--font-heading);
8577 + font-size: var(--font-size-lg);
8578 + font-weight: 700;
8579 + }
8580 +
8581 + .timer-report-total {
8582 + margin: var(--gap-bound) 0 var(--gap-group);
8583 + font-size: var(--font-size-sm);
8584 + color: var(--content-secondary);
8585 + }
8586 +
8587 + .timer-report-rows {
8588 + display: flex;
8589 + flex-direction: column;
8590 + gap: var(--gap-group);
8591 + }
8592 +
8593 + .timer-report-row {
8594 + display: flex;
8595 + flex-direction: column;
8596 + gap: var(--gap-bound);
8597 + }
8598 +
8599 + .timer-report-project {
8600 + display: flex;
8601 + align-items: baseline;
8602 + justify-content: space-between;
8603 + gap: var(--gap-peer);
8604 + }
8605 +
8606 + .timer-report-name {
8607 + font-weight: 600;
8608 + }
8609 +
8610 + .timer-report-tracked {
8611 + font-family: var(--font-mono, monospace);
8612 + font-weight: 700;
8613 + }
8614 +
8615 + .timer-report-bar {
8616 + height: 0.5rem;
8617 + border-radius: var(--radius-xs);
8618 + background: var(--surface-overlay);
8619 + overflow: hidden;
8620 + }
8621 +
8622 + .timer-report-bar-fill {
8623 + height: 100%;
8624 + background: var(--action);
8625 + }
8626 +
8627 + .timer-report-estimate {
8628 + font-size: var(--font-size-sm);
8629 + color: var(--content-secondary);
8630 + }
8631 +
8632 + /* Over 100% is an overrun, under is time left in the estimate. Coloured, but
8633 + the number is there too: the ratio is the finding, not the hue. */
8634 + .timer-report-accuracy.over {
8635 + color: var(--danger);
8636 + font-weight: 600;
8637 + }
8638 +
8639 + .timer-report-accuracy.under {
8640 + color: var(--success);
8641 + font-weight: 600;
8642 + }
8643 +
8644 + .timer-report-none {
8645 + color: var(--content-muted);
8646 + }
8647 +
8488 8648 .timer-focus-split {
8489 8649 display: flex;
8490 8650 align-items: center;
@@ -407,6 +407,7 @@
407 407 listSessions: (taskId) => invoke('list_time_sessions', { taskId }),
408 408 logManual: (taskId, minutes, date) => invoke('log_manual_time', { input: { taskId, minutes, date } }),
409 409 getSummaryPanel: () => invoke('get_time_summary_panel'), // Pre-computed: today total + week per-project rollup (Rust does the math)
410 + getReport: (days) => invoke('get_time_report', { days }), // Per-project tracked + estimated vs actual
410 411 },
411 412
412 413 // Window: Tauri window management
@@ -153,6 +153,10 @@
153 153 }
154 154
155 155 startCountdown();
156 + // The backend session is the same one plain tracking writes, so the widget
157 + // is told which feature owns it; without this a focus session reads as an
158 + // open-ended stopwatch everywhere outside this overlay.
159 + GoingsOn.timeTracking.setMode('focus');
156 160 GoingsOn.timeTracking.checkActive();
157 161 }
158 162
@@ -190,6 +194,7 @@
190 194 console.error('Failed to stop timer on focus end:', err);
191 195 }
192 196
197 + GoingsOn.timeTracking.setMode('track');
193 198 GoingsOn.timeTracking.checkActive();
194 199 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
195 200
@@ -237,6 +242,7 @@
237 242 hideOverlay();
238 243 currentTaskId = null;
239 244 isBreak = false;
245 + GoingsOn.timeTracking.setMode('track');
240 246 }
241 247
242 248 /**
@@ -261,6 +267,7 @@
261 267 hideOverlay();
262 268 currentTaskId = null;
263 269 isBreak = false;
270 + GoingsOn.timeTracking.setMode('track');
264 271 GoingsOn.ui.showToast('Focus session cancelled', 'info');
265 272 }
266 273
@@ -15,6 +15,37 @@
15 15 let tickInterval = null;
16 16 let activeTimer = null; // { taskId, taskDescription, startedAt }
17 17
18 + /**
19 + * Which feature owns the running backend timer: 'track' (an open-ended
20 + * stopwatch) or 'focus' (a pomodoro countdown that happens to record time).
21 + * Both write the same session, so the backend cannot tell them apart and the
22 + * frontend has to say which one the user started. Defaults to 'track', which is
23 + * what a timer found already running after a reload is: the focus countdown
24 + * lives in this process and does not survive one.
25 + */
26 + let activeMode = 'track';
27 +
28 + const MODE_LABELS = { track: 'Tracking', focus: 'Focus session' };
29 +
30 + /**
31 + * Record which feature started the timer. Called by the focus timer around its
32 + * own session, so the widget and the Timer view can name what is running.
33 + * @param {'track'|'focus'} mode
34 + */
35 + function setMode(mode) {
36 + activeMode = mode === 'focus' ? 'focus' : 'track';
37 + applyWidgetMode();
38 + }
39 +
40 + function applyWidgetMode() {
41 + const widget = document.getElementById('timer-widget');
42 + if (!widget) return;
43 + widget.classList.toggle('mode-focus', activeMode === 'focus');
44 + widget.classList.toggle('mode-track', activeMode !== 'focus');
45 + const label = widget.querySelector('.timer-mode-label');
46 + if (label) label.textContent = MODE_LABELS[activeMode] || MODE_LABELS.track;
47 + }
48 +
18 49 // Timer Widget
19 50
20 51 function createWidget() {
@@ -24,6 +55,7 @@
24 55 widget.className = 'timer-widget hidden';
25 56 widget.innerHTML = `
26 57 <div class="timer-widget-inner">
58 + <span class="timer-mode-label"></span>
27 59 <span class="timer-task-name"></span>
28 60 <span class="timer-elapsed"></span>
29 61 <div class="row-flex row-flex-peer">
@@ -42,6 +74,7 @@
42 74 const widget = document.getElementById('timer-widget');
43 75 if (!widget) return;
44 76 widget.querySelector('.timer-task-name').textContent = taskDescription;
77 + applyWidgetMode();
45 78 widget.classList.remove('hidden');
46 79 activeTimer = { startedAt: new Date(startedAt) };
47 80 updateElapsed();
@@ -216,9 +249,9 @@
216 249 // Active session banner
217 250 if (activeResult) {
218 251 html += `
219 - <div class="timer-active-banner">
252 + <div class="timer-active-banner mode-${activeMode === 'focus' ? 'focus' : 'track'}">
220 253 <div class="timer-active-info">
221 - <span class="timer-active-label">Tracking</span>
254 + <span class="timer-active-label">${esc(MODE_LABELS[activeMode] || MODE_LABELS.track)}</span>
222 255 <span class="timer-active-task">${esc(activeResult.taskDescription)}</span>
223 256 </div>
224 257 <span class="timer-active-elapsed" id="timer-subview-elapsed">${fmtElapsed(activeResult.session.startedAt)}</span>
@@ -242,6 +275,20 @@
242 275 }, 1000);
243 276 }
244 277
278 + // The two features, named. They write the same session, so without saying
279 + // so the Track and Focus buttons below read as two words for one button.
280 + html += `
281 + <div class="timer-modes">
282 + <div class="timer-mode-card mode-track">
283 + <h3 class="timer-mode-title">Track</h3>
284 + <p class="timer-mode-desc">An open-ended stopwatch. Runs until you stop it, and records the time against the task.</p>
285 + </div>
286 + <div class="timer-mode-card mode-focus">
287 + <h3 class="timer-mode-title">Focus</h3>
288 + <p class="timer-mode-desc">A countdown of a fixed length, full screen, that stops itself and offers a break. Records the same time.</p>
289 + </div>
290 + </div>`;
291 +
245 292 // Focus split inputs
246 293 html += `
247 294 <div class="timer-focus-split">
@@ -283,7 +330,93 @@
283 330 html += `</div>`;
284 331 }
285 332
333 + html += `<div id="timer-report"></div>`;
334 +
286 335 container.innerHTML = html;
336 +
337 + // Rendered after the container is in the DOM so a slow report never holds
338 + // up the part of the view that can act (start a timer, log time).
339 + loadReport();
340 + }
341 +
342 + // Report
343 +
344 + /** Window in days for the tracked half of the report. */
345 + let reportDays = 7;
346 +
347 + function fmtMinutes(minutes) {
348 + const m = Math.max(0, Math.round(minutes || 0));
349 + if (m < 60) return `${m}m`;
350 + const h = Math.floor(m / 60);
351 + const rest = m % 60;
352 + return rest ? `${h}h ${rest}m` : `${h}h`;
353 + }
354 +
355 + /**
356 + * Load and render the per-project report: tracked time in the window, beside
357 + * estimated versus actual for the same projects.
358 + */
359 + async function loadReport() {
360 + const host = document.getElementById('timer-report');
361 + if (!host) return;
362 +
363 + let report = null;
364 + try {
365 + report = await GoingsOn.api.timeTracking.getReport(reportDays);
366 + } catch (err) {
367 + console.error('[timer] getReport failed:', err);
368 + host.innerHTML = '<p class="time-tracking-empty">Could not load the time report.</p>';
369 + return;
370 + }
371 +
372 + const ranges = [7, 30, 90];
373 + const rangeButtons = ranges.map(d => `
374 + <button class="btn btn-sm ${d === reportDays ? 'btn-primary' : 'btn-secondary'}"
375 + data-act="timeTracking.setReportDays" data-a1="${d}">${d}d</button>
376 + `).join('');
377 +
378 + let rows = '';
379 + if (!report.projects.length) {
380 + rows = '<p class="time-tracking-empty">Nothing tracked or estimated yet.</p>';
381 + } else {
382 + rows = report.projects.map(p => {
383 + // An accuracy of null means nothing in this project carries an
384 + // estimate, which is a different statement from "estimated zero".
385 + const accuracy = p.estimateAccuracyPercent === null || p.estimateAccuracyPercent === undefined
386 + ? '<span class="timer-report-none">no estimates</span>'
387 + : `<span class="timer-report-accuracy ${p.estimateAccuracyPercent > 100 ? 'over' : 'under'}">${p.estimateAccuracyPercent}%</span>`;
388 + const estimate = p.estimatedTaskCount > 0
389 + ? `${fmtMinutes(p.estimatedMinutes)} est / ${fmtMinutes(p.actualMinutes)} actual`
390 + : '<span class="timer-report-none">&mdash;</span>';
391 + return `
392 + <div class="timer-report-row">
393 + <div class="timer-report-project">
394 + <span class="timer-report-name">${esc(p.name)}</span>
395 + <span class="timer-report-tracked">${fmtMinutes(p.trackedMinutes)}</span>
396 + </div>
397 + <div class="timer-report-bar"><div class="timer-report-bar-fill" style="width: ${p.barPercent}%"></div></div>
398 + <div class="timer-report-estimate">${estimate} ${accuracy}</div>
399 + </div>`;
400 + }).join('');
401 + }
402 +
403 + host.innerHTML = `
404 + <div class="timer-report-header">
405 + <h3 class="timer-report-title">Where the time went</h3>
406 + <div class="row-flex row-flex-peer">${rangeButtons}</div>
407 + </div>
408 + <p class="timer-report-total">${fmtMinutes(report.trackedMinutes)} tracked in the last ${reportDays} days. Estimates are lifetime totals over tasks that carry one.</p>
409 + <div class="timer-report-rows">${rows}</div>
410 + `;
411 + }
412 +
413 + /**
414 + * Change the report window and re-render just the report.
415 + * @param {number} days
416 + */
417 + async function setReportDays(days) {
418 + reportDays = parseInt(days, 10) || 7;
419 + await loadReport();
287 420 }
288 421
289 422 function updateFocusSplit() {
@@ -414,8 +547,11 @@
414 547 discardActive,
415 548 checkActive,
416 549 fmtElapsed,
550 + setMode,
417 551 updateRowElapsed,
418 552 loadTimerView,
553 + loadReport,
554 + setReportDays,
419 555 trackFromTimerView,
420 556 focusFromTimerView,
421 557 stopAndRefreshTimerView,
@@ -6,7 +6,10 @@
6 6 use tauri::State;
7 7 use tracing::instrument;
8 8
9 - use goingson_core::{TaskId, TimeSession, TimeSummaryPanel, roll_up_time_summary};
9 + use goingson_core::{
10 + TaskEstimate, TaskId, TimeReport, TimeSession, TimeSummaryPanel, roll_up_time_report,
11 + roll_up_time_summary,
12 + };
10 13
11 14 use super::ApiError;
12 15 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -147,6 +150,45 @@
147 150 Ok(roll_up_time_summary(&rows, &today))
148 151 }
149 152
153 + /// The time report: per-project tracked time over the last `days`, beside
154 + /// estimated versus actual for the same projects.
155 + ///
156 + /// `days` is a window of whole local days ending tonight, so "7" is this week so
157 + /// far plus the tail of last, not a rolling 168 hours from the current minute.
158 + /// The estimate half is not window-scoped: it reads every task carrying an
159 + /// estimate, because "are my estimates any good" is a question about the tasks,
160 + /// not about a date range.
161 + #[tauri::command]
162 + #[instrument(skip_all)]
163 + pub async fn get_time_report(
164 + state: State<'_, Arc<AppState>>,
165 + days: Option<i64>,
166 + ) -> Result<TimeReport, ApiError> {
167 + // Clamped rather than rejected: the window is a view control, and a silly
168 + // value should show a silly-but-sane range instead of an error dialog.
169 + let days = days.unwrap_or(7).clamp(1, 365);
170 + let today = Local::now().date_naive();
171 + let start = local_midnight_utc(today - Duration::days(days - 1));
172 + let end = local_midnight_utc(today + Duration::days(1));
173 +
174 + let (rows, tasks) = tokio::join!(
175 + state.tasks.get_time_summary(DESKTOP_USER_ID, start, end),
176 + state.tasks.list_all(DESKTOP_USER_ID),
177 + );
178 +
179 + let estimates: Vec<TaskEstimate> = tasks?
180 + .into_iter()
181 + .map(|t| TaskEstimate {
182 + project_id: t.project_id,
183 + project_name: t.project_name,
184 + estimated_minutes: t.estimated_minutes,
185 + actual_minutes: t.actual_minutes,
186 + })
187 + .collect();
188 +
189 + Ok(roll_up_time_report(&rows?, &estimates))
190 + }
191 +
150 192 /// Instant of local midnight on `date`, as UTC. On the rare DST-transition day
151 193 /// where local midnight is skipped/ambiguous, falls back to the earliest valid
152 194 /// instant (or the naive value as UTC) rather than panicking.
@@ -180,6 +180,151 @@
180 180 }
181 181 }
182 182
183 + /// One project's row in the time report: what was tracked in the window, beside
184 + /// what its tasks were estimated at and what they have actually taken.
185 + ///
186 + /// The two halves answer different questions and are deliberately not summed
187 + /// together. Tracked is window-scoped ("where did this week go"); estimated and
188 + /// actual are lifetime totals over the project's tasks that carry an estimate
189 + /// ("are my estimates any good"). A project can appear with tracked time and no
190 + /// estimates, or with estimates and nothing tracked this week.
191 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192 + #[serde(rename_all = "camelCase")]
193 + pub struct TimeReportProject {
194 + pub name: String,
195 + /// Minutes tracked inside the report window.
196 + pub tracked_minutes: i32,
197 + /// Sum of `estimated_minutes` over this project's estimated tasks.
198 + pub estimated_minutes: i32,
199 + /// Sum of `actual_minutes` over those same tasks, so the two compare.
200 + pub actual_minutes: i32,
201 + /// How many tasks carry an estimate. Zero means the estimate columns are
202 + /// empty rather than zero, which the view says outright.
203 + pub estimated_task_count: i32,
204 + /// Actual as a percentage of estimated, absent when nothing is estimated.
205 + /// Over 100 is an overrun.
206 + pub estimate_accuracy_percent: Option<i32>,
207 + /// Bar-fill width relative to the most-tracked project, 0-100.
208 + pub bar_percent: i32,
209 + }
210 +
211 + /// A task as the estimate half of the report reads it.
212 + ///
213 + /// Takes the three fields it needs rather than a `Task`, so the roll-up stays
214 + /// pure and testable without building whole task rows.
215 + #[derive(Debug, Clone, PartialEq, Eq)]
216 + pub struct TaskEstimate {
217 + pub project_id: Option<ProjectId>,
218 + pub project_name: Option<String>,
219 + pub estimated_minutes: Option<i32>,
220 + pub actual_minutes: i32,
221 + }
222 +
223 + /// The time report: tracked time in a window, per project, beside estimated
224 + /// versus actual for the same projects.
225 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226 + #[serde(rename_all = "camelCase")]
227 + pub struct TimeReport {
228 + /// Total tracked in the window, across every project.
229 + pub tracked_minutes: i32,
230 + pub projects: Vec<TimeReportProject>,
231 + }
232 +
233 + /// Label for time that belongs to no project. One bucket, matching the panel.
234 + const NO_PROJECT: &str = "No Project";
235 +
236 + /// Roll session rows and task estimates into the per-project time report.
237 + ///
238 + /// Rows and tasks are keyed by project id, so a project renamed between the
239 + /// session and the read still lands in one row. Projects with neither tracked
240 + /// time nor an estimated task do not appear: an empty row says nothing.
241 + pub fn roll_up_time_report(rows: &[TimeTrackingSummary], tasks: &[TaskEstimate]) -> TimeReport {
242 + use std::collections::HashMap;
243 +
244 + // Accumulated per project, in first-seen order so ties sort predictably.
245 + let mut order: Vec<Option<ProjectId>> = Vec::new();
246 + let mut acc: HashMap<Option<ProjectId>, (String, i32, i32, i32, i32)> = HashMap::new();
247 +
248 + fn entry_for<'a>(
249 + key: Option<ProjectId>,
250 + name: Option<&str>,
251 + order: &mut Vec<Option<ProjectId>>,
252 + acc: &'a mut HashMap<Option<ProjectId>, (String, i32, i32, i32, i32)>,
253 + ) -> &'a mut (String, i32, i32, i32, i32) {
254 + acc.entry(key).or_insert_with(|| {
255 + order.push(key);
256 + (
257 + name.filter(|n| !n.trim().is_empty())
258 + .unwrap_or(NO_PROJECT)
259 + .to_string(),
260 + 0,
261 + 0,
262 + 0,
263 + 0,
264 + )
265 + })
266 + }
267 +
268 + for r in rows {
269 + let e = entry_for(
270 + r.project_id,
271 + r.project_name.as_deref(),
272 + &mut order,
273 + &mut acc,
274 + );
275 + e.1 += r.total_minutes;
276 + }
277 +
278 + for t in tasks {
279 + // Only estimated tasks: comparing an estimate that does not exist to the
280 + // time spent would read as a wild overrun on every unestimated task.
281 + let Some(estimate) = t.estimated_minutes.filter(|m| *m > 0) else {
282 + continue;
283 + };
284 + let e = entry_for(
285 + t.project_id,
286 + t.project_name.as_deref(),
287 + &mut order,
288 + &mut acc,
289 + );
290 + e.2 += estimate;
291 + e.3 += t.actual_minutes.max(0);
292 + e.4 += 1;
293 + }
294 +
295 + let tracked_total: i32 = acc.values().map(|e| e.1).sum();
296 + let max_tracked = acc.values().map(|e| e.1).max().unwrap_or(0);
297 +
298 + let mut projects: Vec<TimeReportProject> = order
299 + .into_iter()
300 + .map(|key| {
301 + let (name, tracked, estimated, actual, count) = acc[&key].clone();
302 + TimeReportProject {
303 + name,
304 + tracked_minutes: tracked,
305 + estimated_minutes: estimated,
306 + actual_minutes: actual,
307 + estimated_task_count: count,
308 + estimate_accuracy_percent: (estimated > 0)
309 + .then(|| ((f64::from(actual) / f64::from(estimated)) * 100.0).round() as i32),
310 + bar_percent: if max_tracked > 0 {
311 + ((f64::from(tracked) / f64::from(max_tracked)) * 100.0).round() as i32
312 + } else {
313 + 0
314 + },
315 + }
316 + })
317 + .collect();
318 +
319 + // Most-tracked first, since "where did the week go" is the first question.
320 + projects.sort_by_key(|p| std::cmp::Reverse(p.tracked_minutes));
321 +
322 + TimeReport {
323 + tracked_minutes: tracked_total,
324 + projects,
325 + }
326 + }
327 +
183 328 #[cfg(test)]
184 329 mod tests {
185 330 use super::*;
@@ -254,6 +399,140 @@
254 399 }
255 400 }
256 401
402 + fn estimate(
403 + project_id: Option<ProjectId>,
404 + name: Option<&str>,
405 + estimated: Option<i32>,
406 + actual: i32,
407 + ) -> TaskEstimate {
408 + TaskEstimate {
409 + project_id,
410 + project_name: name.map(String::from),
411 + estimated_minutes: estimated,
412 + actual_minutes: actual,
413 + }
414 + }
415 +
416 + #[test]
417 + fn report_of_nothing_is_empty_rather_than_a_row_of_zeroes() {
418 + let report = roll_up_time_report(&[], &[]);
419 + assert_eq!(report.tracked_minutes, 0);
420 + assert!(report.projects.is_empty());
421 + }
422 +
423 + #[test]
424 + fn report_pairs_tracked_time_with_estimates_per_project() {
425 + let alpha = ProjectId::new();
426 + let beta = ProjectId::new();
427 + let rows = vec![
428 + summary_row(Some(beta), Some("Beta"), "2026-07-01", 30, 1),
429 + summary_row(Some(alpha), Some("Alpha"), "2026-07-01", 60, 2),
430 + summary_row(Some(alpha), Some("Alpha"), "2026-07-04", 30, 1),
431 + ];
432 + let tasks = vec![
433 + estimate(Some(alpha), Some("Alpha"), Some(60), 90),
434 + estimate(Some(alpha), Some("Alpha"), Some(30), 30),
435 + estimate(Some(beta), Some("Beta"), Some(120), 60),
436 + ];
437 +
438 + let report = roll_up_time_report(&rows, &tasks);
439 +
440 + assert_eq!(report.tracked_minutes, 120);
441 + // Most-tracked first.
442 + let a = &report.projects[0];
443 + assert_eq!(a.name, "Alpha");
444 + assert_eq!(a.tracked_minutes, 90);
445 + assert_eq!(a.estimated_minutes, 90);
446 + assert_eq!(a.actual_minutes, 120);
447 + assert_eq!(a.estimated_task_count, 2);
448 + assert_eq!(
449 + a.estimate_accuracy_percent,
450 + Some(133),
451 + "an overrun reads over 100"
452 + );
453 + assert_eq!(a.bar_percent, 100, "the top project fills the bar");
454 +
455 + let b = &report.projects[1];
456 + assert_eq!(b.name, "Beta");
457 + assert_eq!(b.estimate_accuracy_percent, Some(50), "came in under");
458 + assert_eq!(b.bar_percent, 33);
459 + }
460 +
461 + /// An unestimated task must not land in the estimate columns: comparing time
462 + /// spent against an estimate nobody made would read as an infinite overrun.
463 + #[test]
464 + fn report_ignores_tasks_without_an_estimate() {
465 + let alpha = ProjectId::new();
466 + let tasks = vec![
467 + estimate(Some(alpha), Some("Alpha"), None, 240),
468 + estimate(Some(alpha), Some("Alpha"), Some(0), 120),
469 + estimate(Some(alpha), Some("Alpha"), Some(60), 30),
470 + ];
471 +
472 + let report = roll_up_time_report(&[], &tasks);
473 + let a = &report.projects[0];
474 + assert_eq!(a.estimated_task_count, 1);
475 + assert_eq!(a.estimated_minutes, 60);
476 + assert_eq!(a.actual_minutes, 30);
477 + }
478 +
479 + /// The two halves are independent: a project can be tracked with nothing
480 + /// estimated, or estimated with nothing tracked this window.
481 + #[test]
482 + fn report_keeps_a_project_present_on_either_half_alone() {
483 + let tracked_only = ProjectId::new();
484 + let estimated_only = ProjectId::new();
485 +
486 + let report = roll_up_time_report(
487 + &[summary_row(
488 + Some(tracked_only),
489 + Some("Tracked"),
490 + "2026-07-01",
491 + 45,
492 + 1,
493 + )],
494 + &[estimate(
495 + Some(estimated_only),
496 + Some("Estimated"),
497 + Some(60),
498 + 0,
499 + )],
500 + );
501 +
502 + assert_eq!(report.projects.len(), 2);
503 + let tracked = report
504 + .projects
505 + .iter()
506 + .find(|p| p.name == "Tracked")
507 + .unwrap();
508 + assert_eq!(tracked.estimated_task_count, 0);
509 + assert_eq!(
510 + tracked.estimate_accuracy_percent, None,
511 + "no estimate, no ratio"
512 + );
513 + let est = report
514 + .projects
515 + .iter()
516 + .find(|p| p.name == "Estimated")
517 + .unwrap();
518 + assert_eq!(est.tracked_minutes, 0);
519 + assert_eq!(est.bar_percent, 0);
520 + }
521 +
522 + /// Sessions and tasks with no project collapse into one bucket, the same
523 + /// label the day panel uses.
524 + #[test]
525 + fn report_collapses_unassigned_work_into_one_bucket() {
526 + let report = roll_up_time_report(
527 + &[summary_row(None, None, "2026-07-01", 20, 1)],
528 + &[estimate(None, None, Some(30), 45)],
529 + );
530 + assert_eq!(report.projects.len(), 1);
531 + assert_eq!(report.projects[0].name, "No Project");
532 + assert_eq!(report.projects[0].tracked_minutes, 20);
533 + assert_eq!(report.projects[0].estimated_minutes, 30);
534 + }
535 +
257 536 #[test]
258 537 fn roll_up_empty_yields_zeroed_empty_panel() {
259 538 let panel = roll_up_time_summary(&[], "2026-07-04");