From d99deb3c06028ecc8b087464b9c1ad911eac5bcf Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 19:57:22 +0200 Subject: [PATCH] Allow undated reconstructed events --- docs/exhibit-data-model.md | 6 ++-- e2e/board.smoke.spec.ts | 3 ++ migrations/012_nullable_event_occurrence.sql | 4 +++ server/api.integration.test.ts | 15 +++++++++ server/boardClone.ts | 2 +- server/levelRepository.ts | 2 +- server/migrations.integration.test.ts | 8 +++-- src/App.tsx | 34 +++++++++++++++----- src/exhibitRegistry.tsx | 2 +- src/styles.css | 1 + 10 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 migrations/012_nullable_event_occurrence.sql diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index 8572fff..88e3325 100644 --- a/docs/exhibit-data-model.md +++ b/docs/exhibit-data-model.md @@ -148,7 +148,7 @@ CREATE TABLE osint.event_exhibits ( exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE, title TEXT NOT NULL, narrative_text TEXT NOT NULL, - occurred_at TIMESTAMPTZ NOT NULL + occurred_at TIMESTAMPTZ ); ``` @@ -159,10 +159,10 @@ The service validates that every base exhibit has exactly one subtype row matchi An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time. - `narrative_text` states what the investigator believes happened. -- `occurred_at` places that assertion in reconstructed time. +- Nullable `occurred_at` places that assertion in reconstructed time when known. It must never inherit the exhibit's creation timestamp. - One event may cite several supporting exhibits. - One exhibit may support several events. -- Events ordered by `occurred_at` form the emerging case narrative; there is no duplicated story-text record. +- Dated events ordered by `occurred_at` form the temporal narrative. Undated events remain visible in the reconstructed story without affecting the timeline range; there is no duplicated story-text record. Supporting evidence is an explicit normalized relationship: diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 9e8e338..6fa2ff7 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -183,7 +183,10 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click()) await page.reload() await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected') + await expect(page.locator('.evidence-card.event')).toContainText('UNDATED') await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder') + await expect(page.locator('.story-strip')).toContainText('UNDATED') + await expect(page.locator('.timeline .marker')).toHaveCount(1) await expect(page.locator('.event-support-lines line')).toHaveCount(1) page.once('dialog', dialog => dialog.accept('Browser Template')) diff --git a/migrations/012_nullable_event_occurrence.sql b/migrations/012_nullable_event_occurrence.sql new file mode 100644 index 0000000..6bd3ca3 --- /dev/null +++ b/migrations/012_nullable_event_occurrence.sql @@ -0,0 +1,4 @@ +ALTER TABLE osint.event_exhibits + ALTER COLUMN occurred_at DROP NOT NULL; + +COMMENT ON COLUMN osint.event_exhibits.occurred_at IS 'Optional reconstructed occurrence time; never inferred from exhibit creation time'; diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 761a511..2fb9f6b 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -140,6 +140,21 @@ suite('level persistence API', () => { metadata: { witness: 'Integration test', confidence: 'high' }, }) + const undatedState = structuredClone(afterMetadataSave) + const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)! + delete undatedEvent.eventDate + const undatedSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState), + }) + expect(undatedSave.ok).toBe(true) + const loadedUndated = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState + expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined() + expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull() + loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z' + expect((await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated), + })).ok).toBe(true) + const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true) const personPartyId = randomUUID() diff --git a/server/boardClone.ts b/server/boardClone.ts index 3bcef68..1762b4d 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -68,7 +68,7 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text]) - const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date }>( + const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date | null }>( `SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) for (const row of events.rows) await client.query( 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 2a27ffb..a3a8caa 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -247,7 +247,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content]) if (canonicalType === 'event') await client.query( 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', - [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate) || new Date().toISOString()]) + [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)]) if (canonicalType === 'party') { const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index dab946b..0f31cf5 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => { const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const firstRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) - expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(11) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12) const client = new Client({ connectionString: testDatabaseUrl }) await client.connect() @@ -47,14 +47,16 @@ suite('PostgreSQL migrations', () => { ])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') - expect(ledger.rows[0].count).toBe('11') + expect(ledger.rows[0].count).toBe('12') const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) + const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`) + expect(eventOccurrence.rows[0].is_nullable).toBe('YES') await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(11) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index 4387bbb..16a53b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ export function App() { const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) const [editingEventId, setEditingEventId] = useState(null) + const [newEventDraft, setNewEventDraft] = useState(null) const [editingPartyId, setEditingPartyId] = useState(null) const [newPartyDraft, setNewPartyDraft] = useState(null) const [briefOpen, setBriefOpen] = useState(false) @@ -143,9 +144,8 @@ export function App() { const { viewport } = caseState const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', - eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 } - update(state => ({ ...state, evidence: [...state.evidence, event] })) - setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id) + supportingEvidenceIds: [], ...position, width: 270 } + setNewEventDraft(event) } const addParty = () => { @@ -315,7 +315,11 @@ export function App() { ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })), ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })), ].sort((a, b) => dateValue(a.date) - dateValue(b.date)) - const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!)) + const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => { + if (!a.eventDate) return b.eventDate ? 1 : 0 + if (!b.eventDate) return -1 + return dateValue(a.eventDate) - dateValue(b.eventDate) + }) return
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
@@ -355,7 +359,7 @@ export function App() { onLocate={focusEvidence} onEditParty={setEditingPartyId} />} - {storyEvents.length > 0 && } + {storyEvents.length > 0 && } {!docsOpen && }
@@ -388,6 +392,20 @@ export function App() { onClose={() => setEditingEventId(null)} onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} />} + {newEventDraft && setNewEventDraft(null)} + onSave={event => { + update(state => ({ ...state, evidence: [...state.evidence, event] })) + setNewEventDraft(null) + setSelected(event.id) + setRecentlyCreatedExhibitId(event.id) + setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED') + }} + />} {editingPartyId && item.id === editingPartyId)!} @@ -840,7 +858,7 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) const submit = (submitEvent: React.FormEvent) => { submitEvent.preventDefault() - const eventDate = occurredAt ? new Date(occurredAt).toISOString() : new Date().toISOString() + const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports }) } return
@@ -849,13 +867,13 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E EVENT EXHIBIT · THIS HAPPENED