feat: add configurable timeline range
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.';
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExhibitRow>(`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<string, string[]>()
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+34
-7
@@ -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<string | null>(null)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [editingBrief, setEditingBrief] = useState(false)
|
||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -234,7 +235,7 @@ export function App() {
|
||||
<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>
|
||||
<nav>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
</nav>
|
||||
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
||||
</header>
|
||||
@@ -285,7 +286,7 @@ export function App() {
|
||||
</section>
|
||||
|
||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${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('|')}`}/>
|
||||
<Timeline items={temporalItems} selected={selected} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
|
||||
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => 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 && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
|
||||
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => 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 && <FileEditor key={editingFileId} document={caseState.documents.find(document => 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 && <TimelineRangeEditor
|
||||
range={caseState.timelineRange}
|
||||
dates={temporalItems.map(item => item.date)}
|
||||
onClose={() => setEditingTimeline(false)}
|
||||
onSave={timelineRange => {
|
||||
update(state => ({ ...state, timelineRange }))
|
||||
setEditingTimeline(false)
|
||||
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
||||
}}
|
||||
/>}
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
</main>
|
||||
}
|
||||
@@ -464,11 +475,13 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
|
||||
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
|
||||
}
|
||||
|
||||
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 <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
|
||||
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 <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start} — ${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
|
||||
}
|
||||
|
||||
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 <div className="modal-shade"><form className="window timeline-editor" onSubmit={event => { event.preventDefault(); if (valid) onSave({ start, end }) }}>
|
||||
<header><CalendarClock size={16}/><b>Adjust timeline range</b><span/><button type="button" aria-label="Close timeline editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div><small>TEMPORAL VIEWPORT · BOARD SETTING</small><p>Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.</p>
|
||||
<div className="timeline-range-fields"><label className="field"><span>START DATE</span><input aria-label="Timeline start date" type="date" required value={start} onChange={event => setStart(event.target.value)}/></label><label className="field"><span>END DATE</span><input aria-label="Timeline end date" type="date" required value={end} min={start} onChange={event => setEnd(event.target.value)}/></label></div>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={() => onSave(null)}>USE AUTOMATIC RANGE</button><button className="primary" type="submit" disabled={!valid}>APPLY RANGE</button></div>
|
||||
</div>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
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 <aside className="brief-panel"><header><div><small>LEVEL BRIEF</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
|
||||
|
||||
@@ -90,6 +90,14 @@ describe('timeline projection', () => {
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
|
||||
})
|
||||
|
||||
it('uses a valid configured date range without automatic year padding', () => {
|
||||
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
|
||||
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
|
||||
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder domain behavior', () => {
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
import type { CaseState, Evidence, Viewport, WidgetRelation } from './types'
|
||||
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
||||
|
||||
export const MIN_BOARD_ZOOM = 0.45
|
||||
export const MAX_BOARD_ZOOM = 1.5
|
||||
@@ -48,7 +48,14 @@ export function dateValue(date: string) {
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear()) {
|
||||
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
|
||||
if (configured) {
|
||||
const start = Date.parse(`${configured.start}T00:00:00.000Z`)
|
||||
const end = Date.parse(`${configured.end}T23:59:59.999Z`)
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||
return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
|
||||
}
|
||||
}
|
||||
const years = dates
|
||||
.map(date => Number(date.slice(0, 4)))
|
||||
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
|
||||
|
||||
+2
-1
@@ -143,7 +143,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
|
||||
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; }
|
||||
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 0; overflow: hidden; color: #a07142; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 8px IBM Plex Mono; }
|
||||
.timeline-track { height: 75px; margin: 0 43px; position: relative; }
|
||||
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
|
||||
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; }
|
||||
@@ -154,6 +154,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.marker.selected i { border-color: #eea458; background: #eea458; }
|
||||
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
|
||||
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface Connection {
|
||||
}
|
||||
|
||||
export interface Viewport { x: number; y: number; zoom: number }
|
||||
export interface TimelineRange { start: string; end: string }
|
||||
|
||||
export interface BriefConcept {
|
||||
id: string
|
||||
@@ -83,6 +84,7 @@ export interface CaseState {
|
||||
relations: WidgetRelation[]
|
||||
connections: Connection[]
|
||||
viewport: Viewport
|
||||
timelineRange?: TimelineRange | null
|
||||
brief: LevelBrief
|
||||
updatedAt?: string
|
||||
levelStatus?: string
|
||||
|
||||
Reference in New Issue
Block a user