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 && (
+
+ )}
+
);
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);
+ });
});