Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/features/clock/TimesheetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
fromLocalDateTimeInputValue,
getDateRange,
PRESETS,
roundDurationSecondsForDisplay,
toLocalDateTimeInputValue,
type Preset,
} from './timesheetUtils';
Expand Down Expand Up @@ -499,7 +500,7 @@ export const TimesheetPage: React.FC = () => {
Total Hours
</Text>
<Text size="lg" weight="semibold" data-testid="stat-total-hours">
{formatDuration(filteredSummary.totalSeconds)}
{formatDuration(roundDurationSecondsForDisplay(filteredSummary.totalSeconds))}
</Text>
</CardContent>
</Card>
Expand All @@ -509,7 +510,9 @@ export const TimesheetPage: React.FC = () => {
Break Hours
</Text>
<Text size="lg" weight="semibold">
{formatDuration(filteredSummary.totalBreakSeconds)}
{formatDuration(
roundDurationSecondsForDisplay(filteredSummary.totalBreakSeconds),
)}
</Text>
</CardContent>
</Card>
Expand All @@ -529,7 +532,9 @@ export const TimesheetPage: React.FC = () => {
Avg Session
</Text>
<Text size="lg" weight="semibold">
{formatDuration(filteredSummary.averageSessionSeconds)}
{formatDuration(
roundDurationSecondsForDisplay(filteredSummary.averageSessionSeconds),
)}
</Text>
</CardContent>
</Card>
Expand Down Expand Up @@ -656,7 +661,7 @@ export const TimesheetPage: React.FC = () => {
Duration
</Text>
<Text size="sm" weight="medium">
{formatDuration(editDurationSeconds)}
{formatDuration(roundDurationSecondsForDisplay(editDurationSeconds))}
</Text>
</div>
)}
Expand Down Expand Up @@ -819,7 +824,7 @@ export const TimesheetPage: React.FC = () => {
Duration
</Text>
<Text size="sm" weight="medium">
{formatDuration(newDurationSeconds)}
{formatDuration(roundDurationSecondsForDisplay(newDurationSeconds))}
</Text>
</div>
)}
Expand Down
28 changes: 28 additions & 0 deletions src/features/clock/TimesheetRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<table>
<tbody>
<TimesheetRow
session={session}
teams={[{ id: 'team-1', name: 'Rounded Crew' }]}
onEdit={vi.fn()}
/>
</tbody>
</table>,
);

expect(screen.getByText('10:04 AM')).toBeTruthy();
expect(screen.getByText('1:04 PM')).toBeTruthy();
expect(screen.getByText('3h 0m')).toBeTruthy();
});
});
5 changes: 4 additions & 1 deletion src/features/clock/TimesheetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -216,7 +217,9 @@ export const TimesheetRow: React.FC<Props> = ({ session, teams, onEdit }) => {
)}
</TableCell>
<TableCell className="font-mono">
{row.durationSeconds !== null ? formatDuration(row.durationSeconds) : '—'}
{row.durationSeconds !== null
? formatDuration(roundDurationSecondsForDisplay(row.durationSeconds))
: '—'}
</TableCell>
<TableCell>{showTeam ? teamName : ''}</TableCell>
<TableCell>
Expand Down
17 changes: 17 additions & 0 deletions src/features/clock/timesheetUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions src/features/clock/timesheetUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading