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 @@
@@ -46,18 +71,26 @@ const emit = defineEmits<{
Stop the running time entry to edit the timesheet
- emit('update', seconds ?? 0)" />
+
+
+ emit('update', seconds ?? 0)" />
+
+
+
+
+
+
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 };
}