import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; 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'; const addNotification = vi.fn(); vi.mock('@/utils/useUser', () => ({ getCurrentOrganizationId: vi.fn(() => 'org-1'), getCurrentMembershipId: vi.fn(() => 'mem-1'), })); vi.mock('@tanstack/vue-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn(), }), useMutation: (options: { mutationFn: (variables: unknown) => Promise; onSuccess?: (data: unknown, variables: unknown) => void | Promise; onError?: (error: unknown, variables: unknown) => void | Promise; onSettled?: () => void | Promise; }) => ({ mutateAsync: async (variables: unknown) => { try { const data = await options.mutationFn(variables); await options.onSuccess?.(data, variables); return data; } catch (error) { await options.onError?.(error, variables); throw error; } finally { await options.onSettled?.(); } }, }), })); vi.mock('@/utils/notification', () => ({ useNotificationsStore: () => ({ addNotification, }), })); vi.mock('@/packages/api/src', () => ({ api: { createTimeEntry: vi.fn(async () => ({ data: { id: 'new-id' } })), updateTimeEntry: vi.fn(async () => undefined), deleteTimeEntry: vi.fn(async () => undefined), deleteTimeEntries: vi.fn(async () => undefined), }, })); // All scenarios use UTC so the local "day" matches the UTC day exactly // (the test-setup mocks getTimezoneSetting to return 'UTC'). const DATE = '2026-04-10'; const HOUR = 3600; function entry(start: string, end: string | null, overrides: Partial = {}): TimeEntry { const startMs = new Date(start).valueOf(); const endMs = end ? new Date(end).valueOf() : startMs; return { id: overrides.id ?? `e-${start}-${end ?? 'running'}`, start, end, duration: end ? Math.floor((endMs - startMs) / 1000) : null, description: '', member_id: 'm-1', project_id: 'p-1', task_id: null, billable: false, tags: [], ...overrides, } as unknown as TimeEntry; } function buildCell(entries: TimeEntry[]): TimesheetCell { return { dayIndex: 0, date: DATE, entries, totalSeconds: entries.reduce((sum, e) => sum + (e.duration ?? 0), 0), }; } function buildRow( projectId: string | null, entries: TimeEntry[], key = `${projectId}:null` ): TimesheetRow { const cell = buildCell(entries); return { key, projectId, taskId: null, billable: false, tags: [], type: 'work', cells: new Map([[0, cell]]), totalSeconds: cell.totalSeconds, }; } function buildEmptyRow(projectId: string | null, key = `${projectId}:null`): TimesheetRow { return { key, projectId, taskId: null, billable: false, tags: [], type: 'work', cells: new Map(), totalSeconds: 0, }; } /** Shape of the body the cell-mutation logic passes to api.createTimeEntry. */ interface CapturedEntry { id?: string; start: string; end: string | null; project_id?: string | null; task_id?: string | null; description?: string | null; } const apiMocks = vi.mocked(api); function firstArg( mock: typeof apiMocks.createTimeEntry | typeof apiMocks.updateTimeEntry ): CapturedEntry { return mock.mock.calls[0]?.[0] as unknown as CapturedEntry; } function setup( allEntries: TimeEntry[], rowsValue: TimesheetRow[] = [], removeSlot: (key: string) => void = () => undefined ) { const cellMutations = useTimesheetCellMutations( ref([ DATE, '2026-04-11', '2026-04-12', '2026-04-13', '2026-04-14', '2026-04-15', '2026-04-16', ]), ref(allEntries), ref(rowsValue), removeSlot ); return { cellMutations }; } beforeEach(() => { setActivePinia(createPinia()); apiMocks.createTimeEntry.mockClear(); apiMocks.updateTimeEntry.mockClear(); apiMocks.deleteTimeEntry.mockClear(); apiMocks.deleteTimeEntries.mockClear(); addNotification.mockClear(); // Lock the clock to mid-afternoon on the test day so running-entry // tests have a deterministic "now". Past 12:00 to make spillover // scenarios meaningful. vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-10T14:00:00Z')); }); afterEach(() => { vi.useRealTimers(); }); describe('useTimesheetCellMutations.handleCellUpdate', () => { // ── No-op ───────────────────────────────────────────────────── it('does nothing when the diff is zero', async () => { const { cellMutations } = setup([]); const row = buildRow('p-1', [entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z')]); await cellMutations.handleCellUpdate(row, 0, HOUR); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.deleteTimeEntries).not.toHaveBeenCalled(); }); // ── Delete cell ─────────────────────────────────────────────── it('clearing a cell deletes all entries in it', async () => { const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z'); const { cellMutations } = setup([cellEntry]); const row = buildRow('p-1', [cellEntry]); await cellMutations.handleCellUpdate(row, 0, 0); expect(apiMocks.deleteTimeEntries).toHaveBeenCalledTimes(1); const [, options] = apiMocks.deleteTimeEntries.mock.calls[0]!; expect(options?.queries?.ids).toEqual([cellEntry.id]); expect(options?.params?.organization).toBe('org-1'); }); // ── Create cell (Phase 1) ────────────────────────────────────── describe('createCell', () => { it('places a new entry at 09:00 on an empty day', async () => { const { cellMutations } = setup([]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, HOUR); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); const arg = firstArg(apiMocks.createTimeEntry); expect(arg.start).toBe('2026-04-10T09:00:00Z'); expect(arg.end).toBe('2026-04-10T10:00:00Z'); expect(arg.project_id).toBe('p-1'); }); it('passes no-project rows to the API as null', async () => { const { cellMutations } = setup([]); const row = buildEmptyRow(null); await cellMutations.handleCellUpdate(row, 0, HOUR); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); expect(firstArg(apiMocks.createTimeEntry).project_id).toBeNull(); }); it('collapses an empty duplicate row after its first entry is created', async () => { const removeSlot = vi.fn(); const existingRow = buildEmptyRow('p-1', 'existing-slot'); const duplicateRow = buildEmptyRow('p-1', 'duplicate-slot'); const { cellMutations } = setup([], [existingRow, duplicateRow], removeSlot); await cellMutations.handleCellUpdate(duplicateRow, 0, HOUR); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); expect(removeSlot).toHaveBeenCalledWith('duplicate-slot'); expect(addNotification).toHaveBeenCalledWith( 'success', 'Merged into matching row', 'Another row with the same project, task, billable status and tags already exists.' ); }); it("falls back to the start of the day when 09:00 wouldn't fit", async () => { // Block 09:00 → 23:30 with another row's entry. The only // gap big enough for 1h is 00:00 → 09:00. const blocker = entry('2026-04-10T09:00:00Z', '2026-04-10T23:30:00Z', { id: 'blocker', }); const { cellMutations } = setup([blocker]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, HOUR); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); const arg = firstArg(apiMocks.createTimeEntry); expect(arg.start).toBe('2026-04-10T00:00:00Z'); expect(arg.end).toBe('2026-04-10T01:00:00Z'); }); it('avoids overlapping with another row on the same day (Scenario #4)', async () => { // Another row has an entry 09:00 → 10:00. The new entry must // not overlap it. const blocker = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'blocker', }); const { cellMutations } = setup([blocker]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, HOUR); const arg = firstArg(apiMocks.createTimeEntry); // 09:00 is blocked → must place after, at 10:00 expect(arg.start).toBe('2026-04-10T10:00:00Z'); expect(arg.end).toBe('2026-04-10T11:00:00Z'); }); it('treats a running entry as a blocker (Scenario #1)', async () => { const running = entry('2026-04-10T08:00:00Z', null, { id: 'running' }); const { cellMutations } = setup([running]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, HOUR); const arg = firstArg(apiMocks.createTimeEntry); // The running entry blocks [08:00, now=14:00). The 09:00 // work-hours default lands inside that block, so the // search advances the cursor to 14:00 and places the new // entry at 14:00 → 15:00. Critically, this does NOT // overlap the running timer. expect(arg.start).toBe('2026-04-10T14:00:00Z'); expect(arg.end).toBe('2026-04-10T15:00:00Z'); }); it('avoids spillover from the previous day (Scenario #2)', async () => { // An entry from yesterday spills into today's first 4h. const spillover = entry('2026-04-09T22:00:00Z', '2026-04-10T04:00:00Z', { id: 'spillover', }); const { cellMutations } = setup([spillover]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, HOUR); const arg = firstArg(apiMocks.createTimeEntry); // 09:00 is free (the spillover ends at 04:00). expect(arg.start).toBe('2026-04-10T09:00:00Z'); }); it('refuses to cross midnight (Scenario #3)', async () => { // Block all of the day except the last 2h. Asking for 3h // → no single-day window fits → notification, no API call. const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker', }); const { cellMutations } = setup([blocker]); const row = buildEmptyRow('p-1'); await cellMutations.handleCellUpdate(row, 0, 3 * HOUR); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); }); describe('break placement', () => { it('updates an existing break in place when moving it into a valid gap', async () => { const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'morning', type: 'work', }); const afternoon = entry('2026-04-10T13:00:00Z', '2026-04-10T17:00:00Z', { id: 'afternoon', type: 'work', }); const existingBreak = entry('2026-04-10T08:00:00Z', '2026-04-10T08:30:00Z', { id: 'break-1', project_id: null, type: 'break', }); const row = buildRow(null, [existingBreak], 'break-row'); row.type = 'break'; const { cellMutations } = setup([morning, afternoon, existingBreak]); await cellMutations.handleCellUpdate(row, 0, HOUR); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); expect(firstArg(apiMocks.updateTimeEntry)).toEqual( expect.objectContaining({ id: 'break-1', start: '2026-04-10T12:00:00Z', end: '2026-04-10T13:00:00Z', }) ); expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); it('grows a multi-break cell by re-placing the latest break, not fragmenting', async () => { // Two breaks share the break cell. Growing the cell total must extend the // latest-ending break (break-b) in place — never create a third break entry. const w1 = entry('2026-04-10T12:00:00Z', '2026-04-10T14:00:00Z', { id: 'w1', type: 'work', }); const w2 = entry('2026-04-10T15:00:00Z', '2026-04-10T17:00:00Z', { id: 'w2', type: 'work', }); const breakA = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', { id: 'break-a', project_id: null, type: 'break', }); const breakB = entry('2026-04-10T14:00:00Z', '2026-04-10T14:30:00Z', { id: 'break-b', project_id: null, type: 'break', }); const row = buildRow(null, [breakA, breakB], 'break-row'); row.type = 'break'; const { cellMutations } = setup([w1, w2, breakA, breakB]); // Cell total 60m → 90m (the extra 30m lands on break-b, taking it to 60m, // which fills the 14:00-15:00 gap). await cellMutations.handleCellUpdate(row, 0, 90 * 60); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); expect(firstArg(apiMocks.updateTimeEntry)).toEqual( expect.objectContaining({ id: 'break-b', start: '2026-04-10T14:00:00Z', end: '2026-04-10T15:00:00Z', }) ); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled(); }); it('shrinks a multi-break cell by trimming the tail break, not fragmenting', async () => { const breakA = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', { id: 'break-a', project_id: null, type: 'break', }); const breakB = entry('2026-04-10T14:00:00Z', '2026-04-10T14:30:00Z', { id: 'break-b', project_id: null, type: 'break', }); const row = buildRow(null, [breakA, breakB], 'break-row'); row.type = 'break'; const { cellMutations } = setup([breakA, breakB]); // Cell total 60m → 40m: trim 20m off the latest break (break-b → 14:00-14:10). await cellMutations.handleCellUpdate(row, 0, 40 * 60); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); expect(firstArg(apiMocks.updateTimeEntry)).toEqual( expect.objectContaining({ id: 'break-b', end: '2026-04-10T14:10:00Z', }) ); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled(); }); }); // ── Extend cell (Phase 2) ────────────────────────────────────── describe('extendCell', () => { it("extends the cell's latest-ended entry forward when there is room", async () => { const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'extend-me', }); const { cellMutations } = setup([cellEntry]); const row = buildRow('p-1', [cellEntry]); // Cell is 1h, request 2h total → +1h await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.id).toBe('extend-me'); expect(updated.end).toBe('2026-04-10T11:00:00Z'); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); it('picks the latest-END entry when nested entries exist (Scenario #6)', async () => { // Outer entry 09:00 → 12:00, inner entry 10:00 → 11:00. // The latest START is "inner" but the latest END is "outer". // Extending should grow the OUTER entry, not the inner. const outer = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'outer' }); const inner = entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', { id: 'inner' }); const { cellMutations } = setup([outer, inner]); const row = buildRow('p-1', [outer, inner]); // Cell total = 3h + 1h = 4h. Bump to 5h → +1h. await cellMutations.handleCellUpdate(row, 0, 5 * HOUR); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.id).toBe('outer'); expect(updated.end).toBe('2026-04-10T13:00:00Z'); }); it('splits the extension when another row blocks the path (Scenario #5)', async () => { // Cell entry: 09:00 → 10:00 (1h). Blocker on another row: // 10:30 → 11:30. Bump cell to 3h (+2h): // - 30 minutes fit in the gap [10:00, 10:30) → extend to 10:30 // - 90 minutes remain → place a NEW entry in the next free // window (11:30 → 13:00) const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'cell-entry', }); const blocker = entry('2026-04-10T10:30:00Z', '2026-04-10T11:30:00Z', { id: 'blocker', project_id: 'other-project', }); const { cellMutations } = setup([cellEntry, blocker]); const row = buildRow('p-1', [cellEntry]); await cellMutations.handleCellUpdate(row, 0, 3 * HOUR); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.id).toBe('cell-entry'); expect(updated.end).toBe('2026-04-10T10:30:00Z'); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); const created = firstArg(apiMocks.createTimeEntry); // Remainder = 2h - 30min = 90min, placed in next free window // (11:30 → 13:00) expect(created.start).toBe('2026-04-10T11:30:00Z'); expect(created.end).toBe('2026-04-10T13:00:00Z'); }); it("places everything as a new entry when the cell's tail abuts another row immediately (Scenario #5 zero-gap)", async () => { // Cell entry: 09:00 → 10:00. Another row starts EXACTLY at 10:00. // Gap after cell entry = 0. The whole add becomes a new entry // somewhere else. const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'cell-entry', }); const blocker = entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', { id: 'blocker', project_id: 'other-project', }); const { cellMutations } = setup([cellEntry, blocker]); const row = buildRow('p-1', [cellEntry]); // Bump cell from 1h to 2h → +1h await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); // No update to cell entry (gap is zero) expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled(); // New entry placed in next free window: 11:00 → 12:00 expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); const created = firstArg(apiMocks.createTimeEntry); expect(created.start).toBe('2026-04-10T11:00:00Z'); expect(created.end).toBe('2026-04-10T12:00:00Z'); }); it('falls back to creating a new entry when the latest cell entry is running (Scenario #8)', async () => { // Cell contains only a running timer. const running = entry('2026-04-10T08:00:00Z', null, { id: 'running', duration: null, }); // The grid would treat the cell total as 0 because duration is // null, so we fake a non-zero totalSeconds via a manual cell. const cell: TimesheetCell = { dayIndex: 0, date: DATE, entries: [running], // Pretend we computed 1h so that handleCellUpdate sees a // diff > 0 and routes through extendCell rather than // createCell. totalSeconds: HOUR, }; const row: TimesheetRow = { key: 'p-1:null', projectId: 'p-1', taskId: null, billable: false, tags: [], type: 'work', cells: new Map([[0, cell]]), totalSeconds: HOUR, }; const { cellMutations } = setup([running]); // Bump from 1h to 2h → +1h. extendCell sees the running entry // as the latest-end and falls through to createCell. await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); // Running entry blocks [08:00, now=14:00). 09:00 lands inside // → cursor advances to 14:00. Crucially the running entry is // never modified. const created = firstArg(apiMocks.createTimeEntry); expect(created.start).toBe('2026-04-10T14:00:00Z'); expect(created.end).toBe('2026-04-10T15:00:00Z'); }); it('places the remainder strictly after the just-extended end (no stale-overlap)', async () => { // Regression: timeEntries.value is stale right after the // updateEntry call (still shows candidate.end at the old // value). Without an explicit floor, findFreeWindowOnDay // would propose a window inside the gap we just filled — // overlapping the now-extended candidate. // // Cell entry: 09:00 → 10:00 (1h). Blocker on another row at // 11:00 → 12:00. Gap is 1h. Bump cell to 2.5h (+1.5h): // - extend by 1h → newEnd = 11:00 // - remainder = 30 min must land AFTER 11:00, not in the // stale-looking [10:00, 11:00] window. const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'cell-entry', }); const blocker = entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', { id: 'blocker', project_id: 'other-project', }); const { cellMutations } = setup([cellEntry, blocker]); const row = buildRow('p-1', [cellEntry]); await cellMutations.handleCellUpdate(row, 0, Math.round(2.5 * HOUR)); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.id).toBe('cell-entry'); expect(updated.end).toBe('2026-04-10T11:00:00Z'); expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1); const created = firstArg(apiMocks.createTimeEntry); // Next free window after blocker, at 12:00. expect(created.start).toBe('2026-04-10T12:00:00Z'); expect(created.end).toBe('2026-04-10T12:30:00Z'); }); it('refuses extension that would cross midnight before patching the server (Scenario #7)', async () => { // Cell entry 22:00 → 23:00. Bump to 4h (+3h). Only 1h is // available before midnight. Because the remaining 2h don't // fit anywhere else on the day, the mutation now aborts // before issuing the PATCH, leaving the entry untouched. const cellEntry = entry('2026-04-10T22:00:00Z', '2026-04-10T23:00:00Z', { id: 'cell-entry', }); const fillEarly = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'fill', project_id: 'other-project', }); const { cellMutations } = setup([cellEntry, fillEarly]); const row = buildRow('p-1', [cellEntry]); // Bump from 1h to 4h → +3h await cellMutations.handleCellUpdate(row, 0, 4 * HOUR); // Nothing should be patched or created because the preflight // fit-check rejects the whole edit. expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled(); expect(apiMocks.createTimeEntry).not.toHaveBeenCalled(); }); }); // ── Shrink (unchanged behavior, still correct) ───────────────── describe('shrinkFromEnd', () => { it('shortens the latest entry by the requested amount', async () => { const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'shrink-me', }); const { cellMutations } = setup([cellEntry]); const row = buildRow('p-1', [cellEntry]); // 3h → 2h: shrink by 1h await cellMutations.handleCellUpdate(row, 0, 2 * HOUR); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.end).toBe('2026-04-10T11:00:00Z'); }); it('deletes entries that are entirely consumed', async () => { const a = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { id: 'a', }); const b = entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', { id: 'b', }); const { cellMutations } = setup([a, b]); const row = buildRow('p-1', [a, b]); // Cell = 2h, shrink to 0.5h: must delete b entirely (1h) and // shorten a from 1h to 30min. await cellMutations.handleCellUpdate(row, 0, 1800); expect(apiMocks.deleteTimeEntry).toHaveBeenCalledTimes(1); const [, deleteOptions] = apiMocks.deleteTimeEntry.mock.calls[0]!; expect(deleteOptions?.params?.timeEntry).toBe('b'); expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1); const updated = firstArg(apiMocks.updateTimeEntry); expect(updated.id).toBe('a'); expect(updated.end).toBe('2026-04-10T09:30:00Z'); }); }); }); 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); }); });