feat: add configurable timeline range
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user