mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
3 Commits
8682cd1817
...
8969cd8739
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8969cd8739 | ||
|
|
cb5c2547f4 | ||
|
|
13a25524f3 |
3003
package-lock.json
generated
3003
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -26,13 +26,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@inertiajs/vue3": "^2.0.0",
|
"@inertiajs/vue3": "^3.3.0",
|
||||||
"@playwright/test": "^1.41.1",
|
"@playwright/test": "^1.41.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/chroma-js": "^3.1.0",
|
"@types/chroma-js": "^3.1.0",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^25.9.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.0",
|
"@vue/tsconfig": "^0.8.0",
|
||||||
@@ -40,14 +40,14 @@
|
|||||||
"axios": "^1.6.4",
|
"axios": "^1.6.4",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
"laravel-vite-plugin": "^3.1.0",
|
||||||
"openapi-zod-client": "^1.16.2",
|
"openapi-zod-client": "^1.16.2",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^16.1.1",
|
||||||
"postcss-nesting": "^12.1.5",
|
"postcss-nesting": "^14.0.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^8.0.15",
|
||||||
"vite-plugin-checker": "^0.12.0",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vitest": "^4.1.4",
|
"vitest": "^4.1.4",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-form": "^1.3.1",
|
"@tanstack/vue-form": "^1.3.1",
|
||||||
"@tanstack/vue-query": "^5.56.2",
|
"@tanstack/vue-query": "^5.56.2",
|
||||||
"@tanstack/vue-query-devtools": "^5.58.0",
|
"@tanstack/vue-query-devtools": "^6.1.33",
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.3.0",
|
"@vue/eslint-config-typescript": "^14.3.0",
|
||||||
@@ -74,12 +74,12 @@
|
|||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"focus-trap": "^8.0.0",
|
"focus-trap": "^8.0.0",
|
||||||
"lucide-vue-next": "^0.487.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"radix-vue": "^1.9.6",
|
"radix-vue": "^1.9.6",
|
||||||
"reka-ui": "2.8.2",
|
"reka-ui": "2.8.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -62,4 +62,35 @@ describe('TimesheetCell', () => {
|
|||||||
expect(wrapper.emitted('update')).toBeUndefined();
|
expect(wrapper.emitted('update')).toBeUndefined();
|
||||||
expect((input.element as HTMLInputElement).value).toBe(previousValue);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CheckIcon } from '@heroicons/vue/16/solid';
|
||||||
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
||||||
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -7,18 +10,40 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/packages/ui/src/tooltip';
|
} from '@/packages/ui/src/tooltip';
|
||||||
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
|
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
|
||||||
|
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
cell?: TimesheetCell;
|
cell?: TimesheetCell;
|
||||||
dayIndex: number;
|
dayIndex: number;
|
||||||
date: string;
|
date: string;
|
||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
hasRunningEntry: boolean;
|
hasRunningEntry: boolean;
|
||||||
|
saveStatus?: CellSaveStatus;
|
||||||
|
pendingSeconds?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [newSeconds: number];
|
update: [newSeconds: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
|
||||||
|
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
|
||||||
|
const isSaving = computed(() => props.saveStatus === 'saving');
|
||||||
|
|
||||||
|
// Swap the border color (don't layer) to avoid same-specificity fights.
|
||||||
|
const inputClass = computed(() => {
|
||||||
|
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
|
||||||
|
return [
|
||||||
|
'w-[80px] mx-auto text-center font-medium',
|
||||||
|
'bg-transparent text-text-primary placeholder:text-text-quaternary',
|
||||||
|
'rounded-lg border shadow-none',
|
||||||
|
border,
|
||||||
|
'hover:bg-card-background',
|
||||||
|
'focus-visible:bg-tertiary focus-visible:border-transparent',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||||
|
'disabled:cursor-wait disabled:opacity-70',
|
||||||
|
].join(' ');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,18 +71,26 @@ const emit = defineEmits<{
|
|||||||
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DurationSecondsInput
|
<template v-else>
|
||||||
v-else
|
<span class="relative inline-flex items-center">
|
||||||
:model-value="cell?.totalSeconds ?? 0"
|
<DurationSecondsInput
|
||||||
default-unit="hours"
|
:model-value="displaySeconds"
|
||||||
placeholder="-"
|
default-unit="hours"
|
||||||
size="sm"
|
placeholder="-"
|
||||||
input-class="w-[80px] mx-auto text-center font-medium
|
size="sm"
|
||||||
bg-transparent text-text-primary placeholder:text-text-quaternary
|
:disabled="isSaving"
|
||||||
rounded-lg border border-input-border shadow-none
|
:input-class="inputClass"
|
||||||
hover:bg-card-background
|
@commit="(seconds) => emit('update', seconds ?? 0)" />
|
||||||
focus-visible:bg-tertiary focus-visible:border-transparent
|
<span
|
||||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
|
||||||
@commit="(seconds) => emit('update', seconds ?? 0)" />
|
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
|
||||||
|
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
|
||||||
|
<LoadingSpinner
|
||||||
|
v-if="saveStatus === 'saving'"
|
||||||
|
class="h-3 w-3 m-0 text-text-tertiary" />
|
||||||
|
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
|
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
const dayjs = getDayJsInstance();
|
const dayjs = getDayJsInstance();
|
||||||
@@ -36,6 +37,8 @@ defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
formatDuration: (seconds: number) => string;
|
formatDuration: (seconds: number) => string;
|
||||||
|
cellStatuses: Record<string, CellSaveStatus>;
|
||||||
|
cellPendingSeconds: Record<string, number>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -60,7 +63,7 @@ const emit = defineEmits<{
|
|||||||
class="grid min-w-full w-max border-y border-default-background-separator"
|
class="grid min-w-full w-max border-y border-default-background-separator"
|
||||||
style="
|
style="
|
||||||
grid-template-columns:
|
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;
|
40px;
|
||||||
">
|
">
|
||||||
<!-- Header row -->
|
<!-- Header row -->
|
||||||
@@ -100,6 +103,8 @@ const emit = defineEmits<{
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
:format-duration="formatDuration"
|
:format-duration="formatDuration"
|
||||||
|
:cell-statuses="cellStatuses"
|
||||||
|
:cell-pending-seconds="cellPendingSeconds"
|
||||||
@remove-row="$emit('remove-row', $event)"
|
@remove-row="$emit('remove-row', $event)"
|
||||||
@cell-update="
|
@cell-update="
|
||||||
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
|
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import type {
|
|||||||
Organization,
|
Organization,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
|
||||||
|
import {
|
||||||
|
makeCellStatusKey,
|
||||||
|
type CellSaveStatus,
|
||||||
|
} from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
@@ -34,6 +38,8 @@ const props = defineProps<{
|
|||||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||||
createTag: (name: string) => Promise<Tag | undefined>;
|
createTag: (name: string) => Promise<Tag | undefined>;
|
||||||
formatDuration: (seconds: number) => string;
|
formatDuration: (seconds: number) => string;
|
||||||
|
cellStatuses: Record<string, CellSaveStatus>;
|
||||||
|
cellPendingSeconds: Record<string, number>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -109,6 +115,8 @@ function hasRunningEntry(dayIndex: number): boolean {
|
|||||||
:date="day"
|
:date="day"
|
||||||
:is-today="day === todayDate"
|
:is-today="day === todayDate"
|
||||||
:has-running-entry="hasRunningEntry(dayIndex)"
|
: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)" />
|
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
|
||||||
|
|
||||||
<!-- Row total -->
|
<!-- Row total -->
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ const page = usePage<{
|
|||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
|
|
||||||
<NavigationSidebarItem
|
<NavigationSidebarItem
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ const weekRangeDisplay = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
|
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
|
||||||
|
weekDays,
|
||||||
|
timeEntries,
|
||||||
|
rows,
|
||||||
|
removeSlot
|
||||||
|
);
|
||||||
|
|
||||||
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
|
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
|
||||||
mutations,
|
mutations,
|
||||||
@@ -167,6 +172,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
:create-client="createClient"
|
:create-client="createClient"
|
||||||
:create-tag="createTag"
|
:create-tag="createTag"
|
||||||
:format-duration="formatDuration"
|
:format-duration="formatDuration"
|
||||||
|
:cell-statuses="cellStatus"
|
||||||
|
:cell-pending-seconds="cellPendingSeconds"
|
||||||
@remove-row="handleRemoveRow"
|
@remove-row="handleRemoveRow"
|
||||||
@cell-update="handleCellUpdate"
|
@cell-update="handleCellUpdate"
|
||||||
@project-task-change="
|
@project-task-change="
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"author": "solidtime",
|
"author": "solidtime",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.0.3"
|
"vite-plugin-dts": "^5.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.0",
|
"@types/chroma-js": "^3.1.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.0.3",
|
"vite-plugin-dts": "^5.0.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { useTimesheetCellMutations } from './useTimesheetCellMutations';
|
import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
|
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
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 { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
|
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
|
||||||
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
@@ -19,6 +19,17 @@ import {
|
|||||||
type FreeWindow,
|
type FreeWindow,
|
||||||
} from './cellMath';
|
} 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
|
* Cell-level edit dispatcher. Picks one of four strategies based on
|
||||||
* the diff between current and requested totals:
|
* the diff between current and requested totals:
|
||||||
@@ -48,15 +59,58 @@ export function useTimesheetCellMutations(
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const notifications = useNotificationsStore();
|
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(
|
async function handleCellUpdate(
|
||||||
row: TimesheetRow,
|
row: TimesheetRow,
|
||||||
dayIndex: number,
|
dayIndex: number,
|
||||||
newTotalSeconds: number
|
newTotalSeconds: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const statusKey = makeCellStatusKey(row.key, dayIndex);
|
||||||
|
if (cellStatus.value[statusKey] === 'saving') return;
|
||||||
|
|
||||||
const cell = row.cells.get(dayIndex);
|
const cell = row.cells.get(dayIndex);
|
||||||
const existingSeconds = cell?.totalSeconds ?? 0;
|
const existingSeconds = cell?.totalSeconds ?? 0;
|
||||||
if (newTotalSeconds === existingSeconds) return;
|
if (newTotalSeconds === existingSeconds) return;
|
||||||
|
|
||||||
|
beginSaving(statusKey, newTotalSeconds);
|
||||||
|
|
||||||
// Capture row state before the mutation: a row that was empty
|
// Capture row state before the mutation: a row that was empty
|
||||||
// and shares identity with another slot collapses after the
|
// and shares identity with another slot collapses after the
|
||||||
// first entry lands, so the entry naturally identity-routes to
|
// 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.'
|
'Another row with the same project, task, billable status and tags already exists.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
markSaved(statusKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
markError(statusKey);
|
||||||
if (err instanceof NoFreeWindowError) {
|
if (err instanceof NoFreeWindowError) {
|
||||||
const friendlyDuration = formatHumanReadableDuration(
|
const friendlyDuration = formatHumanReadableDuration(
|
||||||
err.requiredSeconds,
|
err.requiredSeconds,
|
||||||
@@ -93,7 +149,6 @@ export function useTimesheetCellMutations(
|
|||||||
'Failed to update timesheet',
|
'Failed to update timesheet',
|
||||||
'Please try again later.'
|
'Please try again later.'
|
||||||
);
|
);
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
}
|
}
|
||||||
@@ -316,5 +371,5 @@ export function useTimesheetCellMutations(
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleCellUpdate };
|
return { handleCellUpdate, cellStatus, cellPendingSeconds };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user