mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
add saved/saving/error indicators to timesheets
This commit is contained in:
@@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, CellSaveStatus>>({});
|
||||
const cellPendingSeconds = ref<Record<string, number>>({});
|
||||
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user