diff --git a/apps/web/lib/entity-id.test.ts b/apps/web/lib/entity-id.test.ts new file mode 100644 index 0000000..cd9d7fe --- /dev/null +++ b/apps/web/lib/entity-id.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createEntityId, dedupeEntityIds } from './entity-id'; + +test('createEntityId stays unique within the same millisecond', () => { + const ids = Array.from({ length: 20 }, () => createEntityId('task', 1782361373857)); + + assert.equal(new Set(ids).size, ids.length); +}); + +test('dedupeEntityIds rewrites duplicate ids while preserving the first item id', () => { + const result = dedupeEntityIds( + [ + { id: 'task-1', title: 'A' }, + { id: 'task-1', title: 'B' }, + { id: 'task-2', title: 'C' }, + { id: 'task-1', title: 'D' }, + ], + 'task', + 1782361373857, + ); + + assert.equal(result.changed, true); + assert.equal(result.items[0].id, 'task-1'); + assert.equal(new Set(result.items.map((item) => item.id)).size, result.items.length); +}); diff --git a/apps/web/lib/entity-id.ts b/apps/web/lib/entity-id.ts new file mode 100644 index 0000000..7f9a9f4 --- /dev/null +++ b/apps/web/lib/entity-id.ts @@ -0,0 +1,43 @@ +let lastTimestamp = 0; +let sameTimestampSequence = 0; + +export interface DedupeEntityIdsResult { + items: T[]; + changed: boolean; +} + +export function createEntityId(prefix: string, timestamp = Date.now()): string { + if (timestamp === lastTimestamp) { + sameTimestampSequence += 1; + } else { + lastTimestamp = timestamp; + sameTimestampSequence = 0; + } + return `${prefix}-${timestamp}-${sameTimestampSequence.toString(36)}`; +} + +export function dedupeEntityIds( + items: T[], + prefix: string, + timestamp = Date.now(), +): DedupeEntityIdsResult { + const seen = new Set(); + let changed = false; + + const deduped = items.map((item) => { + if (item.id && !seen.has(item.id)) { + seen.add(item.id); + return item; + } + + changed = true; + let nextId = createEntityId(prefix, timestamp); + while (seen.has(nextId)) { + nextId = createEntityId(prefix, timestamp); + } + seen.add(nextId); + return { ...item, id: nextId }; + }); + + return { items: changed ? deduped : items, changed }; +} diff --git a/apps/web/stores/useDevTaskStore.ts b/apps/web/stores/useDevTaskStore.ts index 3a20535..3dfb9a7 100644 --- a/apps/web/stores/useDevTaskStore.ts +++ b/apps/web/stores/useDevTaskStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; 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 { loadServerData, saveServerData } from '@/lib/server-data'; function saveStored(items: DevTask[]) { @@ -16,7 +17,9 @@ async function loadStored(): Promise { if (Array.isArray(parsed) && parsed.some(isLegacyTask)) { return null; } - return parsed; + const result = dedupeEntityIds(parsed, 'task'); + if (result.changed) saveStored(result.items); + return result.items; } } catch {} return null; @@ -48,7 +51,7 @@ export const useDevTaskStore = create((set, get) => ({ const now = new Date().toISOString(); const task: DevTask = normalizeDevTaskOnCreate({ ...data, - id: `task-${Date.now()}`, + id: createEntityId('task'), taskNo: generateTaskNo(list), isBlocked: false, createdAt: now, diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts index 80fba2c..f7397fb 100644 --- a/apps/web/stores/useTestCaseStore.ts +++ b/apps/web/stores/useTestCaseStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { TestCase, TestCaseStatus } from '@/lib/test-case'; import { generateCaseNo, normalizeTestCases } from '@/lib/test-case'; import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; +import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; import { loadServerData, saveServerData } from '@/lib/server-data'; function saveStored(items: TestCase[]) { @@ -33,7 +34,11 @@ export const useTestCaseStore = create((set, get) => ({ fetchTestCases: async () => { const cached = await loadStored(); - if (cached) set({ testCases: normalizeTestCases(cached) }); + if (cached) { + const result = dedupeEntityIds(normalizeTestCases(cached), 'tc'); + if (result.changed) saveStored(result.items); + set({ testCases: result.items }); + } }, createTestCase: (data) => { @@ -41,7 +46,7 @@ export const useTestCaseStore = create((set, get) => ({ const now = new Date().toISOString(); const tc: TestCase = normalizeTestCaseOnCreate({ ...data, - id: `tc-${Date.now()}`, + id: createEntityId('tc'), caseNo: generateCaseNo(list), status: 'pending', estimateHours: data.estimateHours ?? 0.5,