From 2d145d7afcabf931519d138cd06e460f04f0fc6a Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Thu, 18 Jun 2026 07:17:59 -0700 Subject: [PATCH] EDIT-2: timestamped status notes in quick-edit (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds editable, timestamped status updates (like comments) to the node quick-edit popover, stored under metadata.statusNotes. Completes #96's primary ask. - New pure lib/statusNotes.ts (add/edit/delete/get) — unit-tested first (7/7), handles metadata as object OR JSON string. - NodeQuickEdit: "Status notes" section — add (timestamped), inline-edit, delete; metadata is serialized to a JSON STRING on write (the schema's metadata field is String — sending an object silently failed; that was the bug the reload test caught), parsed on read. - Quick-edit overlay sources field data from the Apollo nodes array (fresh) for position from the sim node, so reopened editors show saved values. Tests: statusNotes unit 7/7; node-quick-edit @quickedit 3/3 incl. "add a note → survives full reload → delete". THE GATE unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 11 ++- packages/web/src/components/NodeQuickEdit.tsx | 87 ++++++++++++++++++- .../web/src/lib/__tests__/statusNotes.test.ts | 53 +++++++++++ packages/web/src/lib/statusNotes.ts | 49 +++++++++++ tests/e2e/node-quick-edit.spec.ts | 27 ++++++ 5 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/lib/__tests__/statusNotes.test.ts create mode 100644 packages/web/src/lib/statusNotes.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index ab871084..31ca615a 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4986,9 +4986,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i field with immediate optimistic saves + undo, no heavy modal. */} {quickEditNode && (() => { const simNode = (simulationRef.current?.nodes() as any[])?.find((n: any) => n.id === quickEditNode.id); - const node = simNode || quickEditNode; - const gx = node.x ?? 0; - const gy = node.y ?? 0; + // Position from the live sim node, but field DATA (incl. freshly-saved + // metadata/status notes) from the Apollo-backed nodes array so reopening + // the editor shows the latest values, not a stale d3 datum. + const dataNode = (nodes as any[]).find((n: any) => n.id === quickEditNode.id) || quickEditNode; + const gx = simNode?.x ?? dataNode.x ?? 0; + const gy = simNode?.y ?? dataNode.y ?? 0; const left = gx * currentTransform.scale + currentTransform.x; const top = gy * currentTransform.scale + currentTransform.y; const onCommit = (c: QuickEditCommit) => { @@ -5005,7 +5008,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }; return (
- setQuickEditNode(null)} /> + setQuickEditNode(null)} />
); })()} diff --git a/packages/web/src/components/NodeQuickEdit.tsx b/packages/web/src/components/NodeQuickEdit.tsx index cf2a3107..1ca32bb6 100644 --- a/packages/web/src/components/NodeQuickEdit.tsx +++ b/packages/web/src/components/NodeQuickEdit.tsx @@ -1,9 +1,17 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { X, Trash2 } from 'lucide-react'; import { getTypeConfig, getStatusConfig, TYPE_OPTIONS, STATUS_OPTIONS, type WorkItemType, type WorkItemStatus, } from '../constants/workItemConstants'; +import { getStatusNotes, addStatusNote, editStatusNote, deleteStatusNote } from '../lib/statusNotes'; + +function noteId(): string { + try { return crypto.randomUUID(); } catch { return `n-${Date.now()}-${Math.round(Math.random() * 1e6)}`; } +} +function fmtTime(at: number): string { + try { return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return ''; } +} export interface QuickEditCommit { update: Record; @@ -34,6 +42,26 @@ export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quic const [title, setTitle] = useState(node.title || ''); const [description, setDescription] = useState(node.description || ''); const [priority, setPriority] = useState(Math.round(((node.priority ?? 0) as number) * 100)); + // Status notes live in metadata.statusNotes. Keep a local copy so the list + // stays correct within the session regardless of prop-refresh timing. + const [meta, setMeta] = useState(node.metadata ?? {}); + const [noteText, setNoteText] = useState(''); + const [editingNote, setEditingNote] = useState(null); + const notes = getStatusNotes(meta); + + const commitMeta = (next: Record) => { + // metadata is a String field (JSON-as-string) in the schema — serialize on + // write; reads parse it back via getStatusNotes. + const prevStr = typeof meta === 'string' ? meta : JSON.stringify(meta ?? {}); + setMeta(next); + onCommit({ update: { metadata: JSON.stringify(next) }, prev: { metadata: prevStr }, label: 'Status note' }); + }; + const addNote = () => { + const t = noteText.trim(); + if (!t) return; + commitMeta(addStatusNote(meta, t, Date.now(), noteId())); + setNoteText(''); + }; const typeCfg = getTypeConfig(node.type as WorkItemType); @@ -157,6 +185,63 @@ export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quic })} + + {/* Status notes — timestamped, editable updates (like comments) */} +
+ Status notes +
+ setNoteText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNote(); } }} + placeholder="Add a status update…" + className="flex-1 min-w-0 px-2 py-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-200 outline-none focus:border-emerald-400" + /> + +
+ {notes.length > 0 && ( +
    + {notes.map((n) => ( +
  • +
    + {fmtTime(n.at)} + +
    + {editingNote === n.id ? ( + { commitMeta(editStatusNote(meta, n.id, e.target.value)); setEditingNote(null); }} + onKeyDown={(e) => { + if (e.key === 'Enter') { commitMeta(editStatusNote(meta, n.id, (e.target as HTMLInputElement).value)); setEditingNote(null); } + if (e.key === 'Escape') setEditingNote(null); + }} + className="mt-1 w-full px-1.5 py-1 rounded bg-gray-900 border border-emerald-400 text-gray-100 text-[12px] outline-none" + /> + ) : ( +
    setEditingNote(n.id)} title="Click to edit"> + {n.text} +
    + )} +
  • + ))} +
+ )} +
); diff --git a/packages/web/src/lib/__tests__/statusNotes.test.ts b/packages/web/src/lib/__tests__/statusNotes.test.ts new file mode 100644 index 00000000..d857c541 --- /dev/null +++ b/packages/web/src/lib/__tests__/statusNotes.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { + getStatusNotes, addStatusNote, editStatusNote, deleteStatusNote, type StatusNote, +} from '../statusNotes'; + +describe('statusNotes', () => { + it('returns [] for null / undefined / empty metadata', () => { + expect(getStatusNotes(null)).toEqual([]); + expect(getStatusNotes(undefined)).toEqual([]); + expect(getStatusNotes({})).toEqual([]); + }); + + it('parses metadata whether it is an object or a JSON string', () => { + const obj = { statusNotes: [{ id: 'a', text: 'hi', at: 1000 }] }; + expect(getStatusNotes(obj)).toHaveLength(1); + expect(getStatusNotes(JSON.stringify(obj))).toHaveLength(1); + }); + + it('addStatusNote prepends a timestamped note and preserves other metadata', () => { + const md = { tags: ['x'] }; + const next = addStatusNote(md, 'started work', 1700, 'n1'); + const notes = getStatusNotes(next); + expect(notes[0]).toEqual({ id: 'n1', text: 'started work', at: 1700 }); + expect((next as any).tags).toEqual(['x']); // untouched + }); + + it('addStatusNote ignores blank text', () => { + const next = addStatusNote({}, ' ', 1, 'n'); + expect(getStatusNotes(next)).toEqual([]); + }); + + it('newest note is first after multiple adds', () => { + let md: any = {}; + md = addStatusNote(md, 'one', 1, 'a'); + md = addStatusNote(md, 'two', 2, 'b'); + expect(getStatusNotes(md).map((n) => n.id)).toEqual(['b', 'a']); + }); + + it('editStatusNote updates only the matching note text', () => { + let md: any = addStatusNote({}, 'typo', 1, 'a'); + md = addStatusNote(md, 'keep', 2, 'b'); + md = editStatusNote(md, 'a', 'fixed'); + const byId = Object.fromEntries(getStatusNotes(md).map((n) => [n.id, n.text])); + expect(byId).toEqual({ a: 'fixed', b: 'keep' }); + }); + + it('deleteStatusNote removes the matching note', () => { + let md: any = addStatusNote({}, 'a', 1, 'a'); + md = addStatusNote(md, 'b', 2, 'b'); + md = deleteStatusNote(md, 'a'); + expect(getStatusNotes(md).map((n) => n.id)).toEqual(['b']); + }); +}); diff --git a/packages/web/src/lib/statusNotes.ts b/packages/web/src/lib/statusNotes.ts new file mode 100644 index 00000000..853467f3 --- /dev/null +++ b/packages/web/src/lib/statusNotes.ts @@ -0,0 +1,49 @@ +/** + * Status notes — timestamped, editable free-text updates attached to a work item, + * stored under its `metadata.statusNotes` (so no schema change beyond the + * existing metadata JSON field). Pure helpers; the UI + persistence layer call + * these and write the returned metadata back via updateWorkItems. + */ + +export interface StatusNote { + id: string; + text: string; + at: number; // unix ms +} + +type Meta = Record | string | null | undefined; + +function parse(metadata: Meta): Record { + if (!metadata) return {}; + if (typeof metadata === 'string') { + try { return JSON.parse(metadata) || {}; } catch { return {}; } + } + return { ...metadata }; +} + +export function getStatusNotes(metadata: Meta): StatusNote[] { + const m = parse(metadata); + const notes = Array.isArray(m.statusNotes) ? m.statusNotes : []; + return notes.filter((n: any) => n && typeof n.id === 'string' && typeof n.text === 'string'); +} + +/** Prepend a new timestamped note (newest first). Blank text is ignored. */ +export function addStatusNote(metadata: Meta, text: string, at: number, id: string): Record { + const m = parse(metadata); + const t = (text || '').trim(); + if (!t) return m; + const notes = getStatusNotes(m); + return { ...m, statusNotes: [{ id, text: t, at }, ...notes] }; +} + +export function editStatusNote(metadata: Meta, id: string, text: string): Record { + const m = parse(metadata); + const notes = getStatusNotes(m).map((n) => (n.id === id ? { ...n, text: (text || '').trim() } : n)); + return { ...m, statusNotes: notes }; +} + +export function deleteStatusNote(metadata: Meta, id: string): Record { + const m = parse(metadata); + const notes = getStatusNotes(m).filter((n) => n.id !== id); + return { ...m, statusNotes: notes }; +} diff --git a/tests/e2e/node-quick-edit.spec.ts b/tests/e2e/node-quick-edit.spec.ts index d42353a2..8858e0c2 100644 --- a/tests/e2e/node-quick-edit.spec.ts +++ b/tests/e2e/node-quick-edit.spec.ts @@ -60,4 +60,31 @@ test.describe('node quick-edit @quickedit', () => { await page.waitForTimeout(800); } }); + + test('adds a timestamped status note that persists, then deletes it', async ({ page }) => { + await gotoGraph(page); + await page.locator('.graph-container svg .node').first().dblclick(); + await expect(page.locator('[data-testid="node-quick-edit"]')).toBeVisible(); + + const note = `note-${Date.now() % 100000}`; + await page.locator('[data-testid="quick-note-input"]').fill(note); + await page.locator('[data-testid="quick-note-add"]').click(); + // Appears in the list immediately. + await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toBeVisible(); + await page.waitForTimeout(1500); // let the metadata mutation persist + + // Reload (strongest persistence proof) → the note survives, loaded from the + // backend's saved metadata. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }); + await page.waitForTimeout(3500); + await page.locator('.graph-container svg .node').first().dblclick(); + await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toBeVisible({ timeout: 8000 }); + + // Clean up: delete the note so seed data is unchanged. + const item = page.locator('[data-testid="quick-note-list"] li', { hasText: note }).first(); + await item.hover(); + await item.getByRole('button', { name: 'Delete note' }).click(); + await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toHaveCount(0); + }); });