diff --git a/apps/web/components/ServerDataSaveErrorBanner.tsx b/apps/web/components/ServerDataSaveErrorBanner.tsx new file mode 100644 index 0000000..6cb79cf --- /dev/null +++ b/apps/web/components/ServerDataSaveErrorBanner.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { + SERVER_DATA_SAVE_ERROR_EVENT, + type ServerDataKey, + type ServerDataSaveErrorDetail, +} from '@/lib/server-data'; + +const KEY_LABELS: Record = { + 'products-overview': '产品/项目/版本', + requirements: '需求池', + 'version-plans': '版本计划', + 'dev-tasks': '开发任务', + 'test-cases': '测试用例', + bugs: 'BUG', + members: '成员/账号', + 'task-categories': '任务类型', + 'task-worklogs': '工时日志', + 'work-activities': '工作动态', + 'xiaobao-risk-insights': '小宝预警解读', + 'xiaobao-risk-snapshots': '小宝预警快照', + 'xiaobao-warning-views': '小宝预警查看状态', + overtime: '加班记录', +}; + +export function ServerDataSaveErrorBanner() { + const [detail, setDetail] = useState(null); + const dismissTimer = useRef(null); + + useEffect(() => { + function clearDismissTimer() { + if (!dismissTimer.current) return; + window.clearTimeout(dismissTimer.current); + dismissTimer.current = null; + } + + function handleSaveError(event: Event) { + const next = (event as CustomEvent).detail; + if (!next?.key) return; + clearDismissTimer(); + setDetail(next); + dismissTimer.current = window.setTimeout(() => setDetail(null), 10_000); + } + + window.addEventListener(SERVER_DATA_SAVE_ERROR_EVENT, handleSaveError); + return () => { + clearDismissTimer(); + window.removeEventListener(SERVER_DATA_SAVE_ERROR_EVENT, handleSaveError); + }; + }, []); + + if (!detail) return null; + + return ( +
+ +
+

保存失败

+

+ {KEY_LABELS[detail.key]}没有写入服务器。请检查外网 API/网络后重试,必要时刷新页面。 +

+ {detail.message && ( +

{detail.message}

+ )} +
+ +
+ ); +} diff --git a/apps/web/components/layout/LayoutShell.tsx b/apps/web/components/layout/LayoutShell.tsx index c0ab25c..748becc 100644 --- a/apps/web/components/layout/LayoutShell.tsx +++ b/apps/web/components/layout/LayoutShell.tsx @@ -3,6 +3,7 @@ import { usePathname } from 'next/navigation'; import { Sidebar } from '@/components/layout/Sidebar'; import { AuthGuard } from '@/components/AuthGuard'; +import { ServerDataSaveErrorBanner } from '@/components/ServerDataSaveErrorBanner'; export function LayoutShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -10,6 +11,7 @@ export function LayoutShell({ children }: { children: React.ReactNode }) { return ( + {isLoginPage ? ( <>{children} ) : ( diff --git a/apps/web/lib/optimistic-persistence.test.ts b/apps/web/lib/optimistic-persistence.test.ts new file mode 100644 index 0000000..8f49f63 --- /dev/null +++ b/apps/web/lib/optimistic-persistence.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { saveWithOptimisticRollback } from './optimistic-persistence'; + +test('rolls back optimistic state when the save fails and state is unchanged', async () => { + const previous = [{ id: 'old' }]; + const optimistic = [{ id: 'new' }]; + let current = optimistic; + let rolledBack = false; + + await assert.rejects( + () => saveWithOptimisticRollback({ + save: async () => { + throw new Error('save failed'); + }, + expected: optimistic, + getCurrent: () => current, + rollback: () => { + rolledBack = true; + current = previous; + }, + }), + /save failed/, + ); + + assert.equal(rolledBack, true); + assert.equal(current, previous); +}); + +test('does not roll back a newer optimistic state when an older save fails', async () => { + const optimistic = [{ id: 'new' }]; + const newer = [{ id: 'newer' }]; + let current = newer; + let rolledBack = false; + + await assert.rejects( + () => saveWithOptimisticRollback({ + save: async () => { + throw new Error('save failed'); + }, + expected: optimistic, + getCurrent: () => current, + rollback: () => { + rolledBack = true; + }, + }), + /save failed/, + ); + + assert.equal(rolledBack, false); + assert.equal(current, newer); +}); diff --git a/apps/web/lib/optimistic-persistence.ts b/apps/web/lib/optimistic-persistence.ts new file mode 100644 index 0000000..8984555 --- /dev/null +++ b/apps/web/lib/optimistic-persistence.ts @@ -0,0 +1,28 @@ +type OptimisticRollbackOptions = { + save: () => Promise; + expected: T; + getCurrent: () => T; + rollback: () => void; +}; + +export async function saveWithOptimisticRollback({ + save, + expected, + getCurrent, + rollback, +}: OptimisticRollbackOptions): Promise { + try { + await save(); + } catch (error) { + if (Object.is(getCurrent(), expected)) { + rollback(); + } + throw error; + } +} + +export function scheduleSaveWithOptimisticRollback( + options: OptimisticRollbackOptions, +): void { + void saveWithOptimisticRollback(options).catch(() => undefined); +} diff --git a/apps/web/lib/server-data-save-error-source.test.ts b/apps/web/lib/server-data-save-error-source.test.ts new file mode 100644 index 0000000..143f1a9 --- /dev/null +++ b/apps/web/lib/server-data-save-error-source.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +test('server data save failures are surfaced through a global browser event', () => { + const source = readFileSync(join(process.cwd(), 'lib/server-data.ts'), 'utf8'); + + assert.match(source, /export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error';/); + assert.match(source, /window\.dispatchEvent\(\s*new CustomEvent\(SERVER_DATA_SAVE_ERROR_EVENT/); +}); + +test('layout shell renders the global server data save error banner', () => { + const source = readFileSync(join(process.cwd(), 'components/layout/LayoutShell.tsx'), 'utf8'); + + assert.match(source, /import \{ ServerDataSaveErrorBanner \} from '@\/components\/ServerDataSaveErrorBanner';/); + assert.match(source, //); +}); diff --git a/apps/web/lib/server-data.test.ts b/apps/web/lib/server-data.test.ts index ba206a4..21f35b0 100644 --- a/apps/web/lib/server-data.test.ts +++ b/apps/web/lib/server-data.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { loadServerData, saveServerData, ServerDataConflictError } from './server-data'; +import { __resetApiAvailabilityForTests } from './api'; +import { + loadServerData, + saveServerData, + ServerDataConflictError, + SERVER_DATA_SAVE_ERROR_EVENT, + type ServerDataSaveErrorDetail, +} from './server-data'; type MockResponse = { status: number; @@ -172,3 +179,55 @@ test('server data reuses a fresh cached load for repeated AppData reads', async globalThis.fetch = originalFetch; } }); + +test('server data save failures dispatch a browser-visible failure event', async () => { + const originalFetch = globalThis.fetch; + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const events: Array> = []; + __resetApiAvailabilityForTests(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + dispatchEvent: (event: Event) => { + events.push(event as CustomEvent); + return true; + }, + }, + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/config/ai')) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/data/requirements') && init?.method === 'PUT') { + return new Response(JSON.stringify({ message: 'offline' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); + }) as typeof fetch; + + try { + await assert.rejects(() => saveServerData('requirements', []), /offline/); + assert.equal(events.length, 1); + assert.equal(events[0].type, SERVER_DATA_SAVE_ERROR_EVENT); + assert.deepEqual(events[0].detail, { + key: 'requirements', + message: 'offline', + }); + } finally { + globalThis.fetch = originalFetch; + __resetApiAvailabilityForTests(); + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } +}); diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts index d7e0aec..647a6b1 100644 --- a/apps/web/lib/server-data.ts +++ b/apps/web/lib/server-data.ts @@ -37,6 +37,12 @@ const serverDataCache = new Map(); const serverDataLoadPromises = new Map>(); export const SERVER_DATA_CACHE_MS = 30_000; +export const SERVER_DATA_SAVE_ERROR_EVENT = 'ftb:server-data-save-error'; + +export type ServerDataSaveErrorDetail = { + key: ServerDataKey; + message: string; +}; type LoadServerDataOptions = { force?: boolean; @@ -54,6 +60,24 @@ export class ServerDataConflictError extends Error { } } +function getServerDataSaveErrorMessage(error: unknown) { + if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试'; + if (error instanceof Error && error.message) return error.message; + return '保存失败,请检查网络或 API 服务'; +} + +function notifyServerDataSaveFailure(key: ServerDataKey, error: unknown) { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(SERVER_DATA_SAVE_ERROR_EVENT, { + detail: { + key, + message: getServerDataSaveErrorMessage(error), + }, + }), + ); +} + export async function loadServerData( key: ServerDataKey, options: LoadServerDataOptions = {}, @@ -107,12 +131,15 @@ export async function saveServerData(key: ServerDataKey, value: T): Promise(error.body, key) ) { - throw new ServerDataConflictError( + const conflict = new ServerDataConflictError( error.body.key, error.body.currentValue, error.body.currentVersion, ); + notifyServerDataSaveFailure(key, conflict); + throw conflict; } + notifyServerDataSaveFailure(key, error); throw error; } } diff --git a/apps/web/stores/useBugStore.ts b/apps/web/stores/useBugStore.ts index aff2630..9b6e71e 100644 --- a/apps/web/stores/useBugStore.ts +++ b/apps/web/stores/useBugStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { Bug, BugStatus, BugLog } from '@/lib/bug'; import { generateBugNo } from '@/lib/bug'; import { applyBugTransition } from '@/lib/bug-workflow'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeBugCreatedActivity, @@ -11,10 +12,6 @@ import { } from '@/lib/work-activity-factory'; import { useWorkActivityStore } from './useWorkActivityStore'; -function saveStored(items: Bug[]) { - saveServerData('bugs', items).catch(() => {}); -} - let lastBugsFetchAt = 0; async function loadStored(): Promise { @@ -69,23 +66,40 @@ export const useBugStore = create((set, get) => ({ }; const updated = [...list, bug]; set({ bugs: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('bugs', updated), + expected: updated, + getCurrent: () => get().bugs, + rollback: () => set({ bugs: list, loaded: true }), + }); useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator)); return bug; }, updateBug: (id, data) => { - const updated = get().bugs.map((b) => + const previous = get().bugs; + const updated = previous.map((b) => b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b, ); set({ bugs: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('bugs', updated), + expected: updated, + getCurrent: () => get().bugs, + rollback: () => set({ bugs: previous, loaded: true }), + }); }, deleteBug: (id) => { - const updated = get().bugs.filter((b) => b.id !== id); + const previous = get().bugs; + const updated = previous.filter((b) => b.id !== id); set({ bugs: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('bugs', updated), + expected: updated, + getCurrent: () => get().bugs, + rollback: () => set({ bugs: previous, loaded: true }), + }); }, changeStatus: (id, to, operator, extra) => { diff --git a/apps/web/stores/useDevTaskStore.ts b/apps/web/stores/useDevTaskStore.ts index 543e57b..a8911fd 100644 --- a/apps/web/stores/useDevTaskStore.ts +++ b/apps/web/stores/useDevTaskStore.ts @@ -4,6 +4,7 @@ import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; import { generateTaskNo, isLegacyTask } from '@/lib/dev-task'; import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeDevTaskBlockedActivity, @@ -73,23 +74,40 @@ export const useDevTaskStore = create((set, get) => ({ } as DevTask); const updated = [...list, task]; set({ tasks: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('dev-tasks', updated), + expected: updated, + getCurrent: () => get().tasks, + rollback: () => set({ tasks: list, loaded: true }), + }); useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId)); return task; }, updateTask: (id, data) => { - const updated = get().tasks.map((t) => + const previous = get().tasks; + const updated = previous.map((t) => t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t, ); set({ tasks: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('dev-tasks', updated), + expected: updated, + getCurrent: () => get().tasks, + rollback: () => set({ tasks: previous, loaded: true }), + }); }, deleteTask: (id) => { - const updated = get().tasks.filter((t) => t.id !== id); + const previous = get().tasks; + const updated = previous.filter((t) => t.id !== id); set({ tasks: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('dev-tasks', updated), + expected: updated, + getCurrent: () => get().tasks, + rollback: () => set({ tasks: previous, loaded: true }), + }); }, changeStatus: (id, to, opts) => { diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts index ab9407f..9549ec4 100644 --- a/apps/web/stores/useMemberStore.ts +++ b/apps/web/stores/useMemberStore.ts @@ -9,6 +9,7 @@ import { ensureSystemAdminMember, sanitizeSystemAdminPatch, } from '@/lib/member-system'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions'; import { mergePresetRolePermissions } from '@/lib/role-permission-migration'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; @@ -108,38 +109,68 @@ export const useMemberStore = create((set, get) => ({ }, updatePasswordRule: (rule) => { + const previous = get().passwordRule; set({ passwordRule: rule }); - void saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule }), + expected: rule, + getCurrent: () => get().passwordRule, + rollback: () => set({ passwordRule: previous }), + }); }, createDepartment: (data) => { + const previous = get().departments; const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; - const departments = [...get().departments, dept]; + const departments = [...previous, dept]; set({ departments }); - void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }), + expected: departments, + getCurrent: () => get().departments, + rollback: () => set({ departments: previous }), + }); }, updateDepartment: (id, data) => { - const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d); + const previous = get().departments; + const departments = previous.map((d) => d.id === id ? { ...d, ...data } : d); set({ departments }); - void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }), + expected: departments, + getCurrent: () => get().departments, + rollback: () => set({ departments: previous }), + }); }, deleteDepartment: (id) => { - const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id); + const previous = get().departments; + const departments = previous.filter((d) => d.id !== id && d.parentId !== id); set({ departments }); - void saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule }), + expected: departments, + getCurrent: () => get().departments, + rollback: () => set({ departments: previous }), + }); }, createMember: (data) => { + const previous = get().members; const member: Member = { ...data, - username: data.username || generateUsernameFromName(data.name, get().members.map((m) => m.username ?? '')), + username: data.username || generateUsernameFromName(data.name, previous.map((m) => m.username ?? '')), phone: data.phone ?? '', id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10), }; - const members = [...get().members, member]; + const members = [...previous, member]; set({ members }); - void saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }), + expected: members, + getCurrent: () => get().members, + rollback: () => set({ members: previous }), + }); }, updateMember: async (id, data) => { const previousMembers = get().members; @@ -156,24 +187,37 @@ export const useMemberStore = create((set, get) => ({ }, deleteMember: (id) => { if (!canDeleteSystemMember(id)) return; - const members = get().members.filter((m) => m.id !== id); + const previous = get().members; + const members = previous.filter((m) => m.id !== id); set({ members }); - void saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }), + expected: members, + getCurrent: () => get().members, + rollback: () => set({ members: previous }), + }); }, createRole: (data) => { + const previous = get().roles; const role: RoleItem = { ...data, id: `role-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10), permissions: data.permissions ?? [], }; - const roles = [...get().roles, role]; + const roles = [...previous, role]; set({ roles }); - void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }), + expected: roles, + getCurrent: () => get().roles, + rollback: () => set({ roles: previous }), + }); }, updateRole: (id, data) => { - const roles = get().roles.map((r) => { + const previous = get().roles; + const roles = previous.map((r) => { if (r.id !== id) return r; // 超管不允许改 permissions(防误操作锁死系统) if (r.id === 'role-admin' && data.permissions) { @@ -183,12 +227,23 @@ export const useMemberStore = create((set, get) => ({ return { ...r, ...data }; }); set({ roles }); - void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }), + expected: roles, + getCurrent: () => get().roles, + rollback: () => set({ roles: previous }), + }); }, deleteRole: (id) => { if (id === SYSTEM_ADMIN_ROLE_ID) return; - const roles = get().roles.filter((r) => r.id !== id); + const previous = get().roles; + const roles = previous.filter((r) => r.id !== id); set({ roles }); - void saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }).catch(() => {}); + scheduleSaveWithOptimisticRollback({ + save: () => saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule }), + expected: roles, + getCurrent: () => get().roles, + rollback: () => set({ roles: previous }), + }); }, })); diff --git a/apps/web/stores/useOvertimeStore.ts b/apps/web/stores/useOvertimeStore.ts index 929f540..d9d5ed3 100644 --- a/apps/web/stores/useOvertimeStore.ts +++ b/apps/web/stores/useOvertimeStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { OvertimeRecord } from '@/lib/overtime'; import type { DictItem } from '@/lib/requirement'; import { calcDuration } from '@/lib/overtime'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; const PRESET_REASONS: DictItem[] = [ @@ -36,10 +37,6 @@ interface OvertimeState { deleteReason: (id: string) => void; } -function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) { - saveServerData('overtime', data).catch(() => {}); -} - async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> { try { return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime'); @@ -62,6 +59,7 @@ export const useOvertimeStore = create((set, get) => ({ }, createRecord: (data) => { + const previous = get().records; const duration = calcDuration(data.startTime, data.endTime); const record: OvertimeRecord = { ...data, @@ -69,13 +67,19 @@ export const useOvertimeStore = create((set, get) => ({ duration, createdAt: new Date().toISOString().slice(0, 10), }; - const records = [...get().records, record]; + const records = [...previous, record]; set({ records, loaded: true }); - saveStored({ records, reasons: get().reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records, reasons: get().reasons }), + expected: records, + getCurrent: () => get().records, + rollback: () => set({ records: previous, loaded: true }), + }); }, updateRecord: (id, data) => { - const records = get().records.map((r) => { + const previous = get().records; + const records = previous.map((r) => { if (r.id !== id) return r; const updated = { ...r, ...data }; if (data.startTime || data.endTime) { @@ -84,31 +88,60 @@ export const useOvertimeStore = create((set, get) => ({ return updated; }); set({ records, loaded: true }); - saveStored({ records, reasons: get().reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records, reasons: get().reasons }), + expected: records, + getCurrent: () => get().records, + rollback: () => set({ records: previous, loaded: true }), + }); }, deleteRecord: (id) => { - const records = get().records.filter((r) => r.id !== id); + const previous = get().records; + const records = previous.filter((r) => r.id !== id); set({ records, loaded: true }); - saveStored({ records, reasons: get().reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records, reasons: get().reasons }), + expected: records, + getCurrent: () => get().records, + rollback: () => set({ records: previous, loaded: true }), + }); }, addReason: (name) => { + const previous = get().reasons; const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; - const reasons = [...get().reasons, item]; + const reasons = [...previous, item]; set({ reasons, loaded: true }); - saveStored({ records: get().records, reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records: get().records, reasons }), + expected: reasons, + getCurrent: () => get().reasons, + rollback: () => set({ reasons: previous, loaded: true }), + }); }, updateReason: (id, name) => { - const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r); + const previous = get().reasons; + const reasons = previous.map((r) => r.id === id ? { ...r, name } : r); set({ reasons, loaded: true }); - saveStored({ records: get().records, reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records: get().records, reasons }), + expected: reasons, + getCurrent: () => get().reasons, + rollback: () => set({ reasons: previous, loaded: true }), + }); }, deleteReason: (id) => { - const reasons = get().reasons.filter((r) => r.id !== id); + const previous = get().reasons; + const reasons = previous.filter((r) => r.id !== id); set({ reasons, loaded: true }); - saveStored({ records: get().records, reasons }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('overtime', { records: get().records, reasons }), + expected: reasons, + getCurrent: () => get().reasons, + rollback: () => set({ reasons: previous, loaded: true }), + }); }, })); diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index 3a2c075..d95f373 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { Product } from '@ftb/shared'; import { api } from '@/lib/api'; +import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import type { Stage, Role } from '@/lib/stage'; @@ -139,6 +140,7 @@ export const useProductStore = create((set, get) => ({ }, createProduct: async (data) => { + const previousOverview = get().overview; const newProduct: ProductOverview = { id: `local-${Date.now()}`, name: data.name || '', @@ -149,46 +151,81 @@ export const useProductStore = create((set, get) => ({ versions: [], _count: { requirements: 0, projects: 0, versions: 0 }, }; - const updated = [newProduct, ...get().overview]; + const updated = [newProduct, ...previousOverview]; set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, updateProduct: async (id, data) => { - const updated = get().overview.map((p) => + const previousOverview = get().overview; + const previousCurrentProduct = get().currentProduct; + const updated = previousOverview.map((p) => p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p, ); - const currentProduct = get().currentProduct; set({ overview: updated, overviewLoaded: true, currentProduct: - currentProduct?.id === id ? ({ ...currentProduct, ...data } as any) : currentProduct, + previousCurrentProduct?.id === id ? ({ ...previousCurrentProduct, ...data } as any) : previousCurrentProduct, + }); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ + overview: previousOverview, + overviewLoaded: true, + currentProduct: previousCurrentProduct, + }), }); - await saveStoredOverview(updated); }, deleteProduct: async (id) => { - const updated = get().overview.filter((p) => p.id !== id); + const previousOverview = get().overview; + const previousProducts = get().products; + const previousCurrentProduct = get().currentProduct; + const updated = previousOverview.filter((p) => p.id !== id); set({ - products: get().products.filter((p) => p.id !== id), + products: previousProducts.filter((p) => p.id !== id), overview: updated, overviewLoaded: true, - currentProduct: get().currentProduct?.id === id ? null : get().currentProduct, + currentProduct: previousCurrentProduct?.id === id ? null : previousCurrentProduct, + }); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ + products: previousProducts, + overview: previousOverview, + overviewLoaded: true, + currentProduct: previousCurrentProduct, + }), }); - await saveStoredOverview(updated); }, reorderProducts: async (ids) => { - const map = new Map(get().overview.map((p) => [p.id, p])); + const previousOverview = get().overview; + const map = new Map(previousOverview.map((p) => [p.id, p])); const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[]; set({ overview: reordered, overviewLoaded: true }); - await saveStoredOverview(reordered); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(reordered), + expected: reordered, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, migrateAndDeleteProduct: async (sourceId, targetId) => { - const source = get().overview.find((p) => p.id === sourceId); - const target = get().overview.find((p) => p.id === targetId); + const previousOverview = get().overview; + const source = previousOverview.find((p) => p.id === sourceId); + const target = previousOverview.find((p) => p.id === targetId); if (!source || !target) return; const migratedTarget: ProductOverview = { @@ -202,21 +239,27 @@ export const useProductStore = create((set, get) => ({ }, }; - const updated = get().overview + const updated = previousOverview .filter((p) => p.id !== sourceId) .map((p) => (p.id === targetId ? migratedTarget : p)); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, createProject: async (productId, data) => { + const previousOverview = get().overview; const newProject = { id: `proj-${Date.now()}`, name: data.name || '', description: data.description || '', createdAt: new Date().toISOString(), }; - const updated = get().overview.map((p) => { + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; return { ...p, @@ -225,11 +268,17 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, updateProject: async (productId, projectId, data) => { - const updated = get().overview.map((p) => { + const previousOverview = get().overview; + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; const currentProject = p.projects.find((proj) => proj.id === projectId); if (!currentProject) return p; @@ -262,10 +311,16 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, createVersion: async (productId, data) => { + const previousOverview = get().overview; const newVersion: VersionItem = { id: `ver-${Date.now()}`, name: data.name, @@ -274,7 +329,7 @@ export const useProductStore = create((set, get) => ({ createdAt: new Date().toISOString(), ...(data as any), }; - const updated = get().overview.map((p) => { + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; return { ...p, @@ -283,11 +338,17 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, updateVersion: async (productId, versionId, data) => { - const updated = get().overview.map((p) => { + const previousOverview = get().overview; + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; return { ...p, @@ -297,11 +358,17 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, deleteVersion: async (productId, versionId) => { - const updated = get().overview.map((p) => { + const previousOverview = get().overview; + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; return { ...p, @@ -310,11 +377,17 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, deleteProject: async (productId, projectId) => { - const updated = get().overview.map((p) => { + const previousOverview = get().overview; + const updated = previousOverview.map((p) => { if (p.id !== productId) return p; return { ...p, @@ -323,7 +396,12 @@ export const useProductStore = create((set, get) => ({ }; }); set({ overview: updated, overviewLoaded: true }); - await saveStoredOverview(updated); + await saveWithOptimisticRollback({ + save: () => saveStoredOverview(updated), + expected: updated, + getCurrent: () => get().overview, + rollback: () => set({ overview: previousOverview, overviewLoaded: true }), + }); }, })); diff --git a/apps/web/stores/useRequirementStore.ts b/apps/web/stores/useRequirementStore.ts index 9ead757..70152c8 100644 --- a/apps/web/stores/useRequirementStore.ts +++ b/apps/web/stores/useRequirementStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Priority } from '@/lib/derive'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; interface RequirementState { @@ -55,12 +56,6 @@ const PRESET_PLATFORMS: DictItem[] = [ const MOCK_REQUIREMENTS: Requirement[] = []; let lastRequirementsFetchAt = 0; -// --- Server persistence helpers --- - -function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) { - saveServerData('requirements', state).catch(() => {}); -} - async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> { try { return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements'); @@ -103,21 +98,36 @@ export const useRequirementStore = create((set, get) => { }; const updated = [...requirements, newReq]; set({ requirements: updated, loaded: true }); - saveStored({ requirements: updated, sourceTargets, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }), + expected: updated, + getCurrent: () => get().requirements, + rollback: () => set({ requirements, loaded: true }), + }); }, updateRequirement: (id, data) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r)); set({ requirements: updated, loaded: true }); - saveStored({ requirements: updated, sourceTargets, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }), + expected: updated, + getCurrent: () => get().requirements, + rollback: () => set({ requirements, loaded: true }), + }); }, deleteRequirement: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.filter((r) => r.id !== id); set({ requirements: updated, loaded: true }); - saveStored({ requirements: updated, sourceTargets, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements: updated, sourceTargets, types, platforms }), + expected: updated, + getCurrent: () => get().requirements, + rollback: () => set({ requirements, loaded: true }), + }); }, // --- SourceTarget dict --- @@ -126,21 +136,36 @@ export const useRequirementStore = create((set, get) => { const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...sourceTargets, item]; set({ sourceTargets: updated, loaded: true }); - saveStored({ requirements, sourceTargets: updated, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }), + expected: updated, + getCurrent: () => get().sourceTargets, + rollback: () => set({ sourceTargets, loaded: true }), + }); }, updateSourceTarget: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s)); set({ sourceTargets: updated, loaded: true }); - saveStored({ requirements, sourceTargets: updated, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }), + expected: updated, + getCurrent: () => get().sourceTargets, + rollback: () => set({ sourceTargets, loaded: true }), + }); }, deleteSourceTarget: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.filter((s) => s.id !== id); set({ sourceTargets: updated, loaded: true }); - saveStored({ requirements, sourceTargets: updated, types, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets: updated, types, platforms }), + expected: updated, + getCurrent: () => get().sourceTargets, + rollback: () => set({ sourceTargets, loaded: true }), + }); }, // --- Type dict --- @@ -149,21 +174,36 @@ export const useRequirementStore = create((set, get) => { const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...types, item]; set({ types: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types: updated, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }), + expected: updated, + getCurrent: () => get().types, + rollback: () => set({ types, loaded: true }), + }); }, updateType: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.map((t) => (t.id === id ? { ...t, name } : t)); set({ types: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types: updated, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }), + expected: updated, + getCurrent: () => get().types, + rollback: () => set({ types, loaded: true }), + }); }, deleteType: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.filter((t) => t.id !== id); set({ types: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types: updated, platforms }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types: updated, platforms }), + expected: updated, + getCurrent: () => get().types, + rollback: () => set({ types, loaded: true }), + }); }, // --- Platform dict --- @@ -172,21 +212,36 @@ export const useRequirementStore = create((set, get) => { const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...platforms, item]; set({ platforms: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types, platforms: updated }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }), + expected: updated, + getCurrent: () => get().platforms, + rollback: () => set({ platforms, loaded: true }), + }); }, updatePlatform: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p)); set({ platforms: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types, platforms: updated }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }), + expected: updated, + getCurrent: () => get().platforms, + rollback: () => set({ platforms, loaded: true }), + }); }, deletePlatform: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.filter((p) => p.id !== id); set({ platforms: updated, loaded: true }); - saveStored({ requirements, sourceTargets, types, platforms: updated }); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('requirements', { requirements, sourceTargets, types, platforms: updated }), + expected: updated, + getCurrent: () => get().platforms, + rollback: () => set({ platforms, loaded: true }), + }); }, }; }); diff --git a/apps/web/stores/useTaskCategoryStore.ts b/apps/web/stores/useTaskCategoryStore.ts index c792824..58e4c14 100644 --- a/apps/web/stores/useTaskCategoryStore.ts +++ b/apps/web/stores/useTaskCategoryStore.ts @@ -2,12 +2,9 @@ import { create } from 'zustand'; import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; -function saveStored(items: TaskCategory[]) { - saveServerData('task-categories', items).catch(() => {}); -} - let lastCategoriesFetchAt = 0; async function loadStored(): Promise { @@ -52,30 +49,53 @@ export const useTaskCategoryStore = create((set, get) => ({ }; const updated = [...list, item]; set({ categories: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-categories', updated), + expected: updated, + getCurrent: () => get().categories, + rollback: () => set({ categories: list, loaded: true }), + }); }, ensureCategory: (name, group, options) => { + const previous = get().categories; const result = resolveAiTaskCategoryByName(get().categories, name, group, options); if (result.created) { set({ categories: result.categories, loaded: true }); - saveStored(result.categories); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-categories', result.categories), + expected: result.categories, + getCurrent: () => get().categories, + rollback: () => set({ categories: previous, loaded: true }), + }); } return result.category; }, updateCategory: (id, data) => { - const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c)); + const previous = get().categories; + const updated = previous.map((c) => (c.id === id ? { ...c, ...data } : c)); set({ categories: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-categories', updated), + expected: updated, + getCurrent: () => get().categories, + rollback: () => set({ categories: previous, loaded: true }), + }); }, deleteCategory: (id) => { const target = get().categories.find((c) => c.id === id); if (!target || target.isSystem) return false; - const updated = get().categories.filter((c) => c.id !== id); + const previous = get().categories; + const updated = previous.filter((c) => c.id !== id); set({ categories: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-categories', updated), + expected: updated, + getCurrent: () => get().categories, + rollback: () => set({ categories: previous, loaded: true }), + }); return true; }, })); diff --git a/apps/web/stores/useTaskWorklogStore.ts b/apps/web/stores/useTaskWorklogStore.ts index a8e915c..39383ca 100644 --- a/apps/web/stores/useTaskWorklogStore.ts +++ b/apps/web/stores/useTaskWorklogStore.ts @@ -1,12 +1,9 @@ 'use client'; import { create } from 'zustand'; import type { TaskWorklog } from '@/lib/task-worklog'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; -function saveStored(items: TaskWorklog[]) { - saveServerData('task-worklogs', items).catch(() => {}); -} - let lastWorklogsFetchAt = 0; async function loadStored(): Promise { @@ -38,20 +35,32 @@ export const useTaskWorklogStore = create((set, get) => ({ }, addWorklog: (data) => { + const previous = get().worklogs; const item: TaskWorklog = { ...data, id: `wl-${Date.now()}`, createdAt: new Date().toISOString(), }; - const updated = [...get().worklogs, item]; + const updated = [...previous, item]; set({ worklogs: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-worklogs', updated), + expected: updated, + getCurrent: () => get().worklogs, + rollback: () => set({ worklogs: previous, loaded: true }), + }); }, deleteWorklog: (id) => { - const updated = get().worklogs.filter((w) => w.id !== id); + const previous = get().worklogs; + const updated = previous.filter((w) => w.id !== id); set({ worklogs: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('task-worklogs', updated), + expected: updated, + getCurrent: () => get().worklogs, + rollback: () => set({ worklogs: previous, loaded: true }), + }); }, getActualHours: (taskId) => { diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts index 054136a..4f00225 100644 --- a/apps/web/stores/useTestCaseStore.ts +++ b/apps/web/stores/useTestCaseStore.ts @@ -4,6 +4,7 @@ import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-c import { generateCaseNo, normalizeTestCases } from '@/lib/test-case'; import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeTestCaseCreatedActivity, @@ -69,7 +70,12 @@ export const useTestCaseStore = create((set, get) => ({ } as TestCase); const updated = [...list, tc]; set({ testCases: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('test-cases', updated), + expected: updated, + getCurrent: () => get().testCases, + rollback: () => set({ testCases: list, loaded: true }), + }); useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy)); return tc; }, @@ -77,6 +83,7 @@ export const useTestCaseStore = create((set, get) => ({ createTestCases: (items) => { if (items.length === 0) return []; let list = get().testCases; + const previous = list; const now = new Date().toISOString(); const created: TestCase[] = []; for (const data of items) { @@ -92,7 +99,12 @@ export const useTestCaseStore = create((set, get) => ({ list = [...list, tc]; } set({ testCases: list, loaded: true }); - saveStored(list); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('test-cases', list), + expected: list, + getCurrent: () => get().testCases, + rollback: () => set({ testCases: previous, loaded: true }), + }); created.forEach((tc) => { useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy)); }); @@ -100,17 +112,29 @@ export const useTestCaseStore = create((set, get) => ({ }, updateTestCase: (id, data) => { - const updated = get().testCases.map((c) => + const previous = get().testCases; + const updated = previous.map((c) => c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c, ); set({ testCases: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('test-cases', updated), + expected: updated, + getCurrent: () => get().testCases, + rollback: () => set({ testCases: previous, loaded: true }), + }); }, deleteTestCase: (id) => { - const updated = get().testCases.filter((c) => c.id !== id); + const previous = get().testCases; + const updated = previous.filter((c) => c.id !== id); set({ testCases: updated, loaded: true }); - saveStored(updated); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('test-cases', updated), + expected: updated, + getCurrent: () => get().testCases, + rollback: () => set({ testCases: previous, loaded: true }), + }); }, changeStatus: (id, to, extra) => { diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts index a4cf00d..4759178 100644 --- a/apps/web/stores/useVersionPlanStore.ts +++ b/apps/web/stores/useVersionPlanStore.ts @@ -1,6 +1,7 @@ 'use client'; import { create } from 'zustand'; import type { VersionPlan, PlanType } from '@/lib/version-plan'; +import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { getPlanCompletionState } from '@/lib/version-plan-workflow'; import type { PlanResultPayload } from '@/lib/version-plan-workflow'; @@ -17,10 +18,6 @@ import { useWorkActivityStore } from './useWorkActivityStore'; const MOCK_PLANS: VersionPlan[] = []; let lastPlansFetchAt = 0; -function saveStored(plans: VersionPlan[]) { - saveServerData('version-plans', plans).catch(() => {}); -} - async function loadStored(): Promise { try { return await loadServerData('version-plans'); @@ -51,17 +48,24 @@ export const useVersionPlanStore = create((set, get) => ({ }, createPlan: (data) => { + const previous = get().plans; const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; - const plans = [...get().plans, plan]; + const plans = [...previous, plan]; set({ plans, loaded: true }); - saveStored(plans); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('version-plans', plans), + expected: plans, + getCurrent: () => get().plans, + rollback: () => set({ plans: previous, loaded: true }), + }); useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner)); }, updatePlan: (id, data) => { + const previous = get().plans; const now = new Date().toISOString(); const activities: WorkActivityDraft[] = []; - const plans = get().plans.map((p) => { + const plans = previous.map((p) => { if (p.id !== id) return p; const patch = { ...data }; if (patch.status === 'in_progress' && !p.actualStartAt) { @@ -88,13 +92,19 @@ export const useVersionPlanStore = create((set, get) => ({ return next; }); set({ plans, loaded: true }); - saveStored(plans); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('version-plans', plans), + expected: plans, + getCurrent: () => get().plans, + rollback: () => set({ plans: previous, loaded: true }), + }); activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity)); }, completePlan: (id, result) => { let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' }; - const plans = get().plans.map((p) => { + const previous = get().plans; + const plans = previous.map((p) => { if (p.id !== id) return p; const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() }; const state = getPlanCompletionState(next); @@ -107,13 +117,24 @@ export const useVersionPlanStore = create((set, get) => ({ return next; }); set({ plans, loaded: true }); - saveStored(plans); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('version-plans', plans), + expected: plans, + getCurrent: () => get().plans, + rollback: () => set({ plans: previous, loaded: true }), + }); return response; }, deletePlan: (id) => { - const plans = get().plans.filter((p) => p.id !== id); + const previous = get().plans; + const plans = previous.filter((p) => p.id !== id); set({ plans, loaded: true }); - saveStored(plans); + scheduleSaveWithOptimisticRollback({ + save: () => saveServerData('version-plans', plans), + expected: plans, + getCurrent: () => get().plans, + rollback: () => set({ plans: previous, loaded: true }), + }); }, }));