diff --git a/README.md b/README.md index e3532ef..c40eaf1 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ Folder ownership is stored as a normalized membership. The contained document ex An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement. +The timeline uses an optional board-level start and end date. Click **Timeline** in the menu or the displayed range in the footer to adjust it; **Use Automatic Range** returns to evidence-derived bounds. Configured bounds are normalized board data and follow template cloning and reset. Evidence outside the visible interval remains available and is pinned to the nearest timeline edge. + The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data. ## Deploy at osint.glitch.university diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index 9618848..8572fff 100644 --- a/docs/exhibit-data-model.md +++ b/docs/exhibit-data-model.md @@ -9,6 +9,7 @@ Status: accepted design foundation; the core schema, template lifecycle, fronten - A **widget** is the frontend visualization and interaction implementation selected for an exhibit type. - A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file. - A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards. +- A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board. - A **level** is a mutable board copy used for either play or authoring. - A **level template version** is an immutable board snapshot. diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 11c2944..10603bb 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -33,6 +33,18 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible() await expect(page.locator('.evidence-card.folder')).toHaveCount(3) await expect(page.locator('.timeline .marker')).toHaveCount(8) + await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31') + const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox() + const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox() + expect(dispatchMarker).not.toBeNull() + expect(auctionMarker).not.toBeNull() + expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100) + + await page.getByRole('button', { name: 'TIMELINE', exact: true }).click() + await page.getByLabel('Timeline start date').fill('1987-10-10') + await page.getByLabel('Timeline end date').fill('1987-10-26') + await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click()) + await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26') await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.') await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.') diff --git a/migrations/008_timeline_range.sql b/migrations/008_timeline_range.sql new file mode 100644 index 0000000..d6b4275 --- /dev/null +++ b/migrations/008_timeline_range.sql @@ -0,0 +1,8 @@ +CREATE TABLE osint.board_timeline_settings ( + board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE, + range_start DATE NOT NULL, + range_end DATE NOT NULL, + CHECK (range_end > range_start) +); + +COMMENT ON TABLE osint.board_timeline_settings IS 'Optional authored temporal viewport. Absence means derive the timeline range from dated exhibits.'; diff --git a/mysteries/glass-harbor/mystery.json b/mysteries/glass-harbor/mystery.json index e8f7d6a..ceb792f 100644 --- a/mysteries/glass-harbor/mystery.json +++ b/mysteries/glass-harbor/mystery.json @@ -3,6 +3,7 @@ "name": "The Glass Harbor Diversion", "title": "The Glass Harbor Diversion", "subtitle": "Greyhaven file 87-10 · missing lighthouse optics", + "timelineRange": { "start": "1987-10-01", "end": "1987-10-31" }, "brief": { "body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.", "concepts": [ diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 348eb2e..af37167 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -18,6 +18,7 @@ type MysteryManifest = { name: string title: string subtitle: string + timelineRange?: { start: string; end: string } brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] } documents: MysteryDocument[] folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[] @@ -66,6 +67,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()])) state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } + state.timelineRange = manifest.timelineRange state.documents = [...documents.values()] state.evidence = manifest.folders.map(folder => ({ id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index ea594cc..8c6b675 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -68,6 +68,7 @@ suite('level persistence API', () => { expect(createResponse.status).toBe(201) const state = await createResponse.json() as CaseState state.viewport = { x: 91, y: -42, zoom: 0.85 } + state.timelineRange = { start: '2021-04-01', end: '2021-04-30' } const documentId = randomUUID() const folderId = randomUUID() const noteId = randomUUID() @@ -96,12 +97,20 @@ suite('level persistence API', () => { const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState expect(loaded.viewport).toEqual(state.viewport) + expect(loaded.timelineRange).toEqual(state.timelineRange) expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), ])) + const legacyClientState = structuredClone(loaded) + delete legacyClientState.timelineRange + const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState), + }) + expect(legacySave.ok).toBe(true) + expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange) const upload = new FormData() upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') @@ -177,6 +186,7 @@ suite('level persistence API', () => { expect(resetResponse.ok).toBe(true) const resetState = await resetResponse.json() as CaseState expect(resetState.viewport).toEqual(playerState.viewport) + expect(resetState.timelineRange).toEqual(state.timelineRange) expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { @@ -200,6 +210,7 @@ suite('level persistence API', () => { expect(cloneResponse.status).toBe(201) const clone = await cloneResponse.json() as CaseState expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) }) + expect(clone.timelineRange).toEqual(state.timelineRange) expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 }) expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId) expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) diff --git a/server/boardClone.ts b/server/boardClone.ts index 6b18ed5..3a388bf 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -10,6 +10,7 @@ function mapped(ids: IdMap, sourceId: string, label: string) { } export async function clearBoard(client: PoolClient, boardId: string) { + await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId]) @@ -23,6 +24,12 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ const regionIds: IdMap = new Map() const fieldIds: IdMap = new Map() + const timeline = await client.query<{ range_start: string; range_end: string }>( + 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId]) + if (timeline.rows[0]) await client.query( + 'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', + [targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end]) + const exhibits = await client.query<{ id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number rotation: number; z_index: number; hidden: boolean diff --git a/server/levelRepository.ts b/server/levelRepository.ts index b657ce5..06ce8dd 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -64,7 +64,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve const level = await findLevel(pool, slug) if (!level) return null const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, - aliasesResult, partyEvidenceResult, briefResult, conceptsResult] = await Promise.all([ + aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([ pool.query(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content, @@ -111,6 +111,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>( `SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]), + pool.query<{ range_start: string; range_end: string }>( + 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]), ]) const blocks = new Map() @@ -155,6 +157,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), + timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled, sourceTemplateVersionId: level.source_template_version_id || undefined } } @@ -189,6 +192,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos }) const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>( 'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind])) + const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>( + 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) + const existingTimeline = existingTimelineResult.rows[0] + ? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end } + : null await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6, updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom]) @@ -198,6 +206,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id]) + await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id]) @@ -297,6 +306,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve [document.id, fields.get(key), document.metadata[key]]) } const brief = state.brief || { body: '', concepts: [] } + const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange + if (savedTimeline) { + const start = timestamp(savedTimeline.start) + const end = timestamp(savedTimeline.end) + if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start') + await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', + [level.board_id, savedTimeline.start, savedTimeline.end]) + } await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || '']) for (const [sortOrder, concept] of brief.concepts.entries()) { requireUuid(concept.id, 'Brief concept id') diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index 1d29ace..2bdf27d 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(7) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(8) const client = new Client({ connectionString: testDatabaseUrl }) await client.connect() @@ -43,15 +43,16 @@ suite('PostgreSQL migrations', () => { 'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits', 'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations', 'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs', + 'board_timeline_settings', ])) 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('7') + expect(ledger.rows[0].count).toBe('8') await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(7) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(8) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index 5cf083b..16f917d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' -import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types' +import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain' import { documentWidget, exhibitWidget } from './exhibitRegistry' @@ -36,6 +36,7 @@ export function App() { const [editingPartyId, setEditingPartyId] = useState(null) const [briefOpen, setBriefOpen] = useState(false) const [editingBrief, setEditingBrief] = useState(false) + const [editingTimeline, setEditingTimeline] = useState(false) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -234,7 +235,7 @@ export function App() {
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
@@ -285,7 +286,7 @@ export function App() { `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> - { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> + setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} {editingFolderId && widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} @@ -318,6 +319,16 @@ export function App() { setStatus('LEVEL BRIEF UPDATED') }} />} + {editingTimeline && item.date)} + onClose={() => setEditingTimeline(false)} + onSave={timelineRange => { + update(state => ({ ...state, timelineRange })) + setEditingTimeline(false) + setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') + }} + />} {helpOpen && setHelpOpen(false)}/>} } @@ -464,11 +475,13 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: return } -function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) { - const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date)) - const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index) +function Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) { + const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined) const position = (date: string) => timelinePositionPercent(date, { start, end }) - return
TEMPORAL INDEXTIMELINE{items.length} DATED ITEMS
{years.map(year => {year})}{items.map((item, i) => )}
SOURCE SELECTED
+ const ticks = range + ? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } }) + : Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } }) + return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
} function localDateTime(value?: string) { @@ -480,6 +493,20 @@ function localDateTime(value?: string) { return local.toISOString().slice(0, 16) } +function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) { + const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort() + const [start, setStart] = useState(range?.start || dated[0] || '') + const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '') + const valid = Boolean(start && end && end > start) + return
{ event.preventDefault(); if (valid) onSave({ start, end }) }}> +
Adjust timeline range
+
TEMPORAL VIEWPORT · BOARD SETTING

Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.

+
+
+
+
+} + function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) { const partyById = new Map(parties.map(party => [party.id, party])) return