max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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">—</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"); |