From 13a25524f3b1becd07c35f61e0162ee3cd1707b0 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 2 Jun 2026 17:14:32 +0200 Subject: [PATCH] add saved/saving/error indicators to timesheets --- .../Timesheet/TimesheetCell.test.ts | 31 +++++ .../js/Components/Timesheet/TimesheetCell.vue | 61 ++++++--- .../js/Components/Timesheet/TimesheetGrid.vue | 7 +- .../js/Components/Timesheet/TimesheetRow.vue | 8 ++ resources/js/Pages/Timesheet.vue | 9 +- .../useTimesheetCellMutations.test.ts | 118 +++++++++++++++++- .../timesheet/useTimesheetCellMutations.ts | 61 ++++++++- 7 files changed, 275 insertions(+), 20 deletions(-) diff --git a/resources/js/Components/Timesheet/TimesheetCell.test.ts b/resources/js/Components/Timesheet/TimesheetCell.test.ts index f2421ef6..590f470b 100644 --- a/resources/js/Components/Timesheet/TimesheetCell.test.ts +++ b/resources/js/Components/Timesheet/TimesheetCell.test.ts @@ -62,4 +62,35 @@ describe('TimesheetCell', () => { expect(wrapper.emitted('update')).toBeUndefined(); expect((input.element as HTMLInputElement).value).toBe(previousValue); }); + + it('shows a pending 0 (delete in flight) over the cell total', () => { + const wrapper = mount(TimesheetCell, { + props: { + cell: buildCell(2 * 3600), + dayIndex: 0, + date: '2026-04-13', + isToday: false, + hasRunningEntry: false, + pendingSeconds: 0, + }, + }); + + // `??` (not `||`): a pending 0 must win over the 2h cell total. + expect((wrapper.get('input').element as HTMLInputElement).value).toBe(''); + }); + + it('disables editing while the cell is saving', () => { + const wrapper = mount(TimesheetCell, { + props: { + cell: buildCell(2 * 3600), + dayIndex: 0, + date: '2026-04-13', + isToday: false, + hasRunningEntry: false, + saveStatus: 'saving', + }, + }); + + expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true); + }); }); diff --git a/resources/js/Components/Timesheet/TimesheetCell.vue b/resources/js/Components/Timesheet/TimesheetCell.vue index 533e855b..9419b576 100644 --- a/resources/js/Components/Timesheet/TimesheetCell.vue +++ b/resources/js/Components/Timesheet/TimesheetCell.vue @@ -1,5 +1,8 @@ diff --git a/resources/js/Components/Timesheet/TimesheetGrid.vue b/resources/js/Components/Timesheet/TimesheetGrid.vue index cec3da5a..45e2ac72 100644 --- a/resources/js/Components/Timesheet/TimesheetGrid.vue +++ b/resources/js/Components/Timesheet/TimesheetGrid.vue @@ -15,6 +15,7 @@ import type { Task, } from '@/packages/api/src'; import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid'; +import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations'; const organization = inject>('organization'); const dayjs = getDayJsInstance(); @@ -36,6 +37,8 @@ defineProps<{ createClient: (client: CreateClientBody) => Promise; createTag: (name: string) => Promise; formatDuration: (seconds: number) => string; + cellStatuses: Record; + cellPendingSeconds: Record; }>(); const emit = defineEmits<{ @@ -60,7 +63,7 @@ const emit = defineEmits<{ class="grid min-w-full w-max border-y border-default-background-separator" style=" grid-template-columns: - minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto) + minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto) 40px; "> @@ -100,6 +103,8 @@ const emit = defineEmits<{ :create-client="createClient" :create-tag="createTag" :format-duration="formatDuration" + :cell-statuses="cellStatuses" + :cell-pending-seconds="cellPendingSeconds" @remove-row="$emit('remove-row', $event)" @cell-update=" (dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds) diff --git a/resources/js/Components/Timesheet/TimesheetRow.vue b/resources/js/Components/Timesheet/TimesheetRow.vue index b3501e8a..e55b7337 100644 --- a/resources/js/Components/Timesheet/TimesheetRow.vue +++ b/resources/js/Components/Timesheet/TimesheetRow.vue @@ -15,6 +15,10 @@ import type { Organization, } from '@/packages/api/src'; import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid'; +import { + makeCellStatusKey, + type CellSaveStatus, +} from '@/utils/timesheet/useTimesheetCellMutations'; import { Button } from '@/packages/ui/src/Buttons'; const organization = inject>('organization'); @@ -34,6 +38,8 @@ const props = defineProps<{ createClient: (client: CreateClientBody) => Promise; createTag: (name: string) => Promise; formatDuration: (seconds: number) => string; + cellStatuses: Record; + cellPendingSeconds: Record; }>(); const emit = defineEmits<{ @@ -109,6 +115,8 @@ function hasRunningEntry(dayIndex: number): boolean { :date="day" :is-today="day === todayDate" :has-running-entry="hasRunningEntry(dayIndex)" + :save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]" + :pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]" @update="(seconds) => emit('cellUpdate', dayIndex, seconds)" /> diff --git a/resources/js/Pages/Timesheet.vue b/resources/js/Pages/Timesheet.vue index 40c997e7..858d710f 100644 --- a/resources/js/Pages/Timesheet.vue +++ b/resources/js/Pages/Timesheet.vue @@ -90,7 +90,12 @@ const weekRangeDisplay = computed(() => { }); // ── Cell / row mutation handlers ────────────────────────────────── -const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot); +const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations( + weekDays, + timeEntries, + rows, + removeSlot +); const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations( mutations, @@ -167,6 +172,8 @@ async function createTag(name: string): Promise { :create-client="createClient" :create-tag="createTag" :format-duration="formatDuration" + :cell-statuses="cellStatus" + :cell-pending-seconds="cellPendingSeconds" @remove-row="handleRemoveRow" @cell-update="handleCellUpdate" @project-task-change=" diff --git a/resources/js/utils/timesheet/useTimesheetCellMutations.test.ts b/resources/js/utils/timesheet/useTimesheetCellMutations.test.ts index 91a48261..52ee1795 100644 --- a/resources/js/utils/timesheet/useTimesheetCellMutations.test.ts +++ b/resources/js/utils/timesheet/useTimesheetCellMutations.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; -import { useTimesheetCellMutations } from './useTimesheetCellMutations'; +import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations'; import { api } from '@/packages/api/src'; import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid'; import type { TimeEntry } from '@/packages/api/src'; @@ -549,3 +549,119 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => { }); }); }); + +describe('useTimesheetCellMutations save status', () => { + // Timer handles keep old fade-outs from clearing newer status, and + // the same-cell saving guard prevents concurrent writes from stale rows. + + it('does not let a stale fade-out timer clear a newer edit on the same cell', async () => { + const { cellMutations } = setup([]); + const row = buildEmptyRow('p-1'); + const key = makeCellStatusKey(row.key, 0); + + await cellMutations.handleCellUpdate(row, 0, HOUR); + expect(cellMutations.cellStatus.value[key]).toBe('saved'); + + // Re-edit the same cell partway through the first "saved" window. + vi.advanceTimersByTime(1000); + await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); + expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR); + + // Advance past the FIRST timer's deadline: it must not wipe the newer state. + vi.advanceTimersByTime(2000); + expect(cellMutations.cellStatus.value[key]).toBe('saved'); + expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR); + }); + + it('ignores another commit while the same cell is saving', async () => { + const { cellMutations } = setup([]); + const row = buildEmptyRow('p-1'); + const key = makeCellStatusKey(row.key, 0); + + let release!: () => void; + const gateA = new Promise((res) => { + release = () => res(); + }); + apiMocks.createTimeEntry.mockImplementationOnce(async () => { + await gateA; + return { data: { id: 'a' } } as never; + }); + + const save = cellMutations.handleCellUpdate(row, 0, HOUR); + expect(cellMutations.cellStatus.value[key]).toBe('saving'); + expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR); + + // The second commit would be planned from the same stale row, so it is ignored. + await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); + expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); + expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR); + + release(); + await save; + expect(cellMutations.cellStatus.value[key]).toBe('saved'); + expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR); + }); + + it('marks error and drops the optimistic value when the save fails', async () => { + const { cellMutations } = setup([]); + const row = buildEmptyRow('p-1'); + const key = makeCellStatusKey(row.key, 0); + + apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom')); + + await cellMutations.handleCellUpdate(row, 0, HOUR); + + expect(cellMutations.cellStatus.value[key]).toBe('error'); + expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined(); + expect(addNotification).toHaveBeenCalledWith( + 'error', + 'Failed to update timesheet', + expect.any(String) + ); + }); + + it('marks error and drops the optimistic value when the day is full', async () => { + // Block all but the last 2h, then ask for 3h → NoFreeWindowError. + const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker' }); + const { cellMutations } = setup([blocker]); + const row = buildEmptyRow('p-1'); + const key = makeCellStatusKey(row.key, 0); + + await cellMutations.handleCellUpdate(row, 0, 3 * HOUR); + + expect(cellMutations.cellStatus.value[key]).toBe('error'); + expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined(); + expect(addNotification).toHaveBeenCalledWith( + 'error', + "This day can't fit any more work", + expect.any(String) + ); + }); + + it('creates no status when the committed value is unchanged', async () => { + const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z'); + const { cellMutations } = setup([cellEntry]); + const row = buildRow('p-1', [cellEntry]); + const key = makeCellStatusKey(row.key, 0); + + await cellMutations.handleCellUpdate(row, 0, HOUR); + + expect(cellMutations.cellStatus.value[key]).toBeUndefined(); + expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined(); + }); + + it('tracks save status independently for each cell', async () => { + const { cellMutations } = setup([]); + const row = buildEmptyRow('p-1'); + const mondayKey = makeCellStatusKey(row.key, 0); + const tuesdayKey = makeCellStatusKey(row.key, 1); + + await cellMutations.handleCellUpdate(row, 0, HOUR); + await cellMutations.handleCellUpdate(row, 1, 2 * HOUR); + + expect(cellMutations.cellStatus.value[mondayKey]).toBe('saved'); + expect(cellMutations.cellStatus.value[tuesdayKey]).toBe('saved'); + expect(cellMutations.cellPendingSeconds.value[mondayKey]).toBe(HOUR); + expect(cellMutations.cellPendingSeconds.value[tuesdayKey]).toBe(2 * HOUR); + }); +}); diff --git a/resources/js/utils/timesheet/useTimesheetCellMutations.ts b/resources/js/utils/timesheet/useTimesheetCellMutations.ts index e9abec0e..120b2b2f 100644 --- a/resources/js/utils/timesheet/useTimesheetCellMutations.ts +++ b/resources/js/utils/timesheet/useTimesheetCellMutations.ts @@ -1,4 +1,4 @@ -import type { Ref } from 'vue'; +import { ref, type Ref } from 'vue'; import { useQueryClient } from '@tanstack/vue-query'; import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src'; import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time'; @@ -19,6 +19,17 @@ import { type FreeWindow, } from './cellMath'; +export type CellSaveStatus = 'saving' | 'saved' | 'error'; + +/** Map key for a cell's save state (row + day). */ +export function makeCellStatusKey(rowKey: TimesheetRowKey, dayIndex: number): string { + return `${rowKey}:${dayIndex}`; +} + +/** How long the saved/error state stays visible before fading. */ +const SAVED_VISIBLE_MS = 2800; +const ERROR_VISIBLE_MS = 2500; + /** * Cell-level edit dispatcher. Picks one of four strategies based on * the diff between current and requested totals: @@ -48,15 +59,58 @@ export function useTimesheetCellMutations( const queryClient = useQueryClient(); const notifications = useNotificationsStore(); + // Save status + the optimistic value shown while saving, so a saved cell + // doesn't flicker back to its old total before the refetch lands. + const cellStatus = ref>({}); + const cellPendingSeconds = ref>({}); + const statusClearTimers: Record> = {}; + + function clearStatusTimer(key: string): void { + clearTimeout(statusClearTimers[key]); + delete statusClearTimers[key]; + } + + function beginSaving(key: string, seconds: number): void { + clearStatusTimer(key); + cellPendingSeconds.value[key] = seconds; + cellStatus.value[key] = 'saving'; + } + + function markSaved(key: string): void { + clearStatusTimer(key); + cellStatus.value[key] = 'saved'; + statusClearTimers[key] = setTimeout(() => { + delete cellStatus.value[key]; + delete cellPendingSeconds.value[key]; + delete statusClearTimers[key]; + }, SAVED_VISIBLE_MS); + } + + function markError(key: string): void { + clearStatusTimer(key); + cellStatus.value[key] = 'error'; + // Drop the optimistic value so the cell shows server truth after refetch. + delete cellPendingSeconds.value[key]; + statusClearTimers[key] = setTimeout(() => { + delete cellStatus.value[key]; + delete statusClearTimers[key]; + }, ERROR_VISIBLE_MS); + } + async function handleCellUpdate( row: TimesheetRow, dayIndex: number, newTotalSeconds: number ): Promise { + const statusKey = makeCellStatusKey(row.key, dayIndex); + if (cellStatus.value[statusKey] === 'saving') return; + const cell = row.cells.get(dayIndex); const existingSeconds = cell?.totalSeconds ?? 0; if (newTotalSeconds === existingSeconds) return; + beginSaving(statusKey, newTotalSeconds); + // Capture row state before the mutation: a row that was empty // and shares identity with another slot collapses after the // first entry lands, so the entry naturally identity-routes to @@ -74,7 +128,9 @@ export function useTimesheetCellMutations( 'Another row with the same project, task, billable status and tags already exists.' ); } + markSaved(statusKey); } catch (err) { + markError(statusKey); if (err instanceof NoFreeWindowError) { const friendlyDuration = formatHumanReadableDuration( err.requiredSeconds, @@ -93,7 +149,6 @@ export function useTimesheetCellMutations( 'Failed to update timesheet', 'Please try again later.' ); - throw err; } finally { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); } @@ -316,5 +371,5 @@ export function useTimesheetCellMutations( return best; } - return { handleCellUpdate }; + return { handleCellUpdate, cellStatus, cellPendingSeconds }; }