From f0fdb2ac4e8bb822fe455cb1607aaea7c6c536f4 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Mon, 15 Jun 2026 11:33:04 -0400 Subject: [PATCH] ui: consistently round durations and time stamps --- src/features/clock/TimesheetPage.tsx | 15 ++++++++---- src/features/clock/TimesheetRow.test.tsx | 28 +++++++++++++++++++++++ src/features/clock/TimesheetRow.tsx | 5 +++- src/features/clock/timesheetUtils.test.ts | 17 ++++++++++++++ src/features/clock/timesheetUtils.ts | 12 ++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 src/features/clock/timesheetUtils.test.ts diff --git a/src/features/clock/TimesheetPage.tsx b/src/features/clock/TimesheetPage.tsx index 175532e2..75696538 100644 --- a/src/features/clock/TimesheetPage.tsx +++ b/src/features/clock/TimesheetPage.tsx @@ -44,6 +44,7 @@ import { fromLocalDateTimeInputValue, getDateRange, PRESETS, + roundDurationSecondsForDisplay, toLocalDateTimeInputValue, type Preset, } from './timesheetUtils'; @@ -499,7 +500,7 @@ export const TimesheetPage: React.FC = () => { Total Hours - {formatDuration(filteredSummary.totalSeconds)} + {formatDuration(roundDurationSecondsForDisplay(filteredSummary.totalSeconds))} @@ -509,7 +510,9 @@ export const TimesheetPage: React.FC = () => { Break Hours - {formatDuration(filteredSummary.totalBreakSeconds)} + {formatDuration( + roundDurationSecondsForDisplay(filteredSummary.totalBreakSeconds), + )} @@ -529,7 +532,9 @@ export const TimesheetPage: React.FC = () => { Avg Session - {formatDuration(filteredSummary.averageSessionSeconds)} + {formatDuration( + roundDurationSecondsForDisplay(filteredSummary.averageSessionSeconds), + )} @@ -656,7 +661,7 @@ export const TimesheetPage: React.FC = () => { Duration - {formatDuration(editDurationSeconds)} + {formatDuration(roundDurationSecondsForDisplay(editDurationSeconds))} )} @@ -819,7 +824,7 @@ export const TimesheetPage: React.FC = () => { Duration - {formatDuration(newDurationSeconds)} + {formatDuration(roundDurationSecondsForDisplay(newDurationSeconds))} )} diff --git a/src/features/clock/TimesheetRow.test.tsx b/src/features/clock/TimesheetRow.test.tsx index f7fcad6f..31e8dc1b 100644 --- a/src/features/clock/TimesheetRow.test.tsx +++ b/src/features/clock/TimesheetRow.test.tsx @@ -167,4 +167,32 @@ describe('TimesheetRow', () => { fireEvent.click(editButtons[0]); expect(onEdit).toHaveBeenCalledWith(session); }); + + it('rounds duration display to match minute-level timestamps', () => { + const session: ClockEvent = { + id: 'session-rounded', + userId: 'u1', + teamId: 'team-1', + startTime: new Date('2026-05-19T10:04:30').getTime(), + endTime: new Date('2026-05-19T13:04:29').getTime(), + accumulatedTime: 0, + breaks: [], + }; + + render( + + + + +
, + ); + + expect(screen.getByText('10:04 AM')).toBeTruthy(); + expect(screen.getByText('1:04 PM')).toBeTruthy(); + expect(screen.getByText('3h 0m')).toBeTruthy(); + }); }); diff --git a/src/features/clock/TimesheetRow.tsx b/src/features/clock/TimesheetRow.tsx index 7c1375d4..247420e2 100644 --- a/src/features/clock/TimesheetRow.tsx +++ b/src/features/clock/TimesheetRow.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { formatDate, formatDuration, formatTime } from '../../lib/timeUtils'; import { type ClockEvent } from '../../lib/api'; +import { roundDurationSecondsForDisplay } from './timesheetUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -216,7 +217,9 @@ export const TimesheetRow: React.FC = ({ session, teams, onEdit }) => { )} - {row.durationSeconds !== null ? formatDuration(row.durationSeconds) : '—'} + {row.durationSeconds !== null + ? formatDuration(roundDurationSecondsForDisplay(row.durationSeconds)) + : '—'} {showTeam ? teamName : ''} diff --git a/src/features/clock/timesheetUtils.test.ts b/src/features/clock/timesheetUtils.test.ts new file mode 100644 index 00000000..84f45e4d --- /dev/null +++ b/src/features/clock/timesheetUtils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { roundDurationSecondsForDisplay } from './timesheetUtils'; + +describe('roundDurationSecondsForDisplay', () => { + it('rounds to the nearest minute for display', () => { + expect(roundDurationSecondsForDisplay(2 * 3600 + 59 * 60 + 29)).toBe(2 * 3600 + 59 * 60); + expect(roundDurationSecondsForDisplay(2 * 3600 + 59 * 60 + 30)).toBe(3 * 3600); + }); + + it('returns 0 for null, zero, negative, or non-finite values', () => { + expect(roundDurationSecondsForDisplay(null)).toBe(0); + expect(roundDurationSecondsForDisplay(0)).toBe(0); + expect(roundDurationSecondsForDisplay(-10)).toBe(0); + expect(roundDurationSecondsForDisplay(Number.NaN)).toBe(0); + }); +}); diff --git a/src/features/clock/timesheetUtils.ts b/src/features/clock/timesheetUtils.ts index 227a27bd..84ce0b24 100644 --- a/src/features/clock/timesheetUtils.ts +++ b/src/features/clock/timesheetUtils.ts @@ -64,6 +64,18 @@ export function fromLocalDateTimeInputValue(value: string): number | null { return Number.isNaN(ms) ? null : ms; } +/** + * Round a second-based duration to the nearest whole minute for display. + * + * Timesheet timestamps are shown at minute precision, so durations should + * follow the same rounding rule to avoid off-by-one-minute displays. + */ +export function roundDurationSecondsForDisplay(totalSeconds: number | null): number { + const value = typeof totalSeconds === 'number' ? totalSeconds : 0; + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.round(value / 60) * 60; +} + export const PRESETS: { key: Preset; label: string }[] = [ { key: 'today', label: 'Today' }, { key: 'yesterday', label: 'Yesterday' },