Allow undated reconstructed events
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+26
-8
@@ -40,6 +40,7 @@ export function App() {
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
||||
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
|
||||
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
||||
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(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 <main className="desktop">
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
@@ -355,7 +359,7 @@ export function App() {
|
||||
onLocate={focusEvidence}
|
||||
onEditParty={setEditingPartyId}
|
||||
/>}
|
||||
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
|
||||
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
|
||||
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
|
||||
<div className="board-actions">
|
||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||
@@ -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 && <EventEditor
|
||||
key={newEventDraft.id}
|
||||
event={newEventDraft}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
onClose={() => 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 && <PartyEditor
|
||||
key={editingPartyId}
|
||||
party={caseState.evidence.find(item => 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 <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
|
||||
@@ -849,13 +867,13 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
|
||||
<small>EVENT EXHIBIT · THIS HAPPENED</small>
|
||||
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
|
||||
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
|
||||
<label className="field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL</span><input aria-label="Occurred at" type="datetime-local" required value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/></label>
|
||||
<label className="field event-date-field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL · OPTIONAL</span><span><input aria-label="Occurred at" type="datetime-local" value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/><button type="button" disabled={!occurredAt} onClick={() => setOccurredAt('')}>CLEAR DATE</button></span></label>
|
||||
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
|
||||
<div className="folder-members event-support-list">
|
||||
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
|
||||
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
|
||||
</div>
|
||||
<p className="folder-editor-note">An event is an investigator assertion. Its occurrence time is separate from the publication time of the documents supporting it.</p>
|
||||
<p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
|
||||
</div>
|
||||
</form></div>
|
||||
|
||||
@@ -37,7 +37,7 @@ function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
|
||||
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
|
||||
const supportCount = exhibit.supportingEvidenceIds?.length || 0
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
{exhibit.eventDate && <time>{new Date(exhibit.eventDate).toLocaleString()}</time>}
|
||||
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
|
||||
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
|
||||
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
|
||||
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
|
||||
.event-date-field > span:last-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }.event-date-field > span:last-child button { border: 1px outset #8c948e; background: #c8cbc4; color: #4d5853; padding: 0 10px; cursor: pointer; font: 8px IBM Plex Mono; }.event-date-field > span:last-child button:disabled { opacity: .45; cursor: default; }
|
||||
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
|
||||
.folder-member.included { background: #e2d7bd; opacity: 1; }
|
||||
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
|
||||
|
||||
Reference in New Issue
Block a user