test: add board and postgres safety net
This commit is contained in:
@@ -30,6 +30,22 @@ npm run dev
|
|||||||
|
|
||||||
Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
|
Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
Run the fast domain tests without external services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
With the development PostgreSQL container running, exercise the migration ledger and persistence API against uniquely named disposable databases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
|
||||||
|
|
||||||
## Data and API
|
## Data and API
|
||||||
|
|
||||||
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
||||||
|
|||||||
+5
-5
@@ -14,16 +14,16 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
|||||||
- [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally.
|
- [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally.
|
||||||
- [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`.
|
- [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`.
|
||||||
- [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository.
|
- [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository.
|
||||||
- [ ] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
|
- [x] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
|
||||||
|
|
||||||
## Milestone 1: focused safety net
|
## Milestone 1: focused safety net
|
||||||
|
|
||||||
- [ ] Test screen/board coordinate conversion across pan and zoom levels.
|
- [x] Test screen/board coordinate conversion across pan and zoom levels.
|
||||||
- [ ] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
||||||
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
|
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
|
||||||
- [ ] Test folder open/close behavior, retained file positions, and containment bands.
|
- [x] Test folder open/close behavior and retained file positions; add visual coverage for containment bands with the browser smoke test.
|
||||||
- [ ] Test document upload, metadata persistence, board save/reload, and reset.
|
- [ ] Test document upload, metadata persistence, board save/reload, and reset.
|
||||||
- [ ] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
||||||
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence.
|
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence.
|
||||||
|
|
||||||
## Milestone 2: exhibit-schema cutover
|
## Milestone 2: exhibit-schema cutover
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "NODE_ENV=production tsx server/index.ts",
|
"start": "NODE_ENV=production tsx server/index.ts",
|
||||||
"migrate:up": "tsx server/migrate.ts",
|
"migrate:up": "tsx server/migrate.ts",
|
||||||
"test": "vitest run"
|
"test": "vitest run --exclude '**/*.integration.test.ts'",
|
||||||
|
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createServer } from 'node:net'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import pg from 'pg'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import type { CaseState } from '../src/types.js'
|
||||||
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
|
const { Client } = pg
|
||||||
|
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||||
|
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||||
|
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
|
||||||
|
let adminClient: InstanceType<typeof Client>
|
||||||
|
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||||
|
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||||
|
let baseUrl = ''
|
||||||
|
|
||||||
|
async function availablePort() {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const probe = createServer()
|
||||||
|
probe.once('error', reject)
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address()
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0
|
||||||
|
probe.close(error => error ? reject(error) : resolve(port))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
suite('level persistence API', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const adminUrl = new URL(baseDatabaseUrl!)
|
||||||
|
adminUrl.pathname = '/postgres'
|
||||||
|
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||||
|
await adminClient.connect()
|
||||||
|
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||||
|
const testUrl = new URL(baseDatabaseUrl!)
|
||||||
|
testUrl.pathname = `/${databaseName}`
|
||||||
|
const databaseUrl = testUrl.toString()
|
||||||
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
|
await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
||||||
|
|
||||||
|
const port = await availablePort()
|
||||||
|
process.env.DATABASE_URL = databaseUrl
|
||||||
|
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||||
|
process.env.PORT = String(port)
|
||||||
|
const serverModule = await import('./index.js')
|
||||||
|
appServer = serverModule.server
|
||||||
|
appPool = serverModule.pool
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||||
|
if (appPool) await appPool.end()
|
||||||
|
if (!adminClient) return
|
||||||
|
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
|
||||||
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||||
|
await adminClient.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists authored viewport, exhibits, and relation positions', async () => {
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/levels`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
||||||
|
})
|
||||||
|
expect(createResponse.status).toBe(201)
|
||||||
|
const state = await createResponse.json() as CaseState
|
||||||
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||||
|
state.documents = [{ id: 'doc-1', title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }]
|
||||||
|
state.evidence = [{ id: 'folder-1', type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: ['doc-1'] }]
|
||||||
|
state.relations = [{ id: 'membership-1', fromWidgetId: 'folder-1', toWidgetId: 'doc-1', type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
||||||
|
|
||||||
|
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(state),
|
||||||
|
})
|
||||||
|
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
|
||||||
|
|
||||||
|
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
|
expect(loaded.viewport).toEqual(state.viewport)
|
||||||
|
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
|
||||||
|
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
+11
-4
@@ -16,7 +16,7 @@ if (!databaseUrl) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: databaseUrl })
|
export const pool = new Pool({ connectionString: databaseUrl })
|
||||||
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||||
|
|
||||||
type WidgetRow = {
|
type WidgetRow = {
|
||||||
@@ -154,6 +154,10 @@ async function savePlaythrough(client: PoolClient, state: CaseState) {
|
|||||||
|
|
||||||
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
|
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
|
||||||
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
|
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
|
||||||
|
await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at)
|
||||||
|
VALUES ($1, $2, $3::jsonb, NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`,
|
||||||
|
[`default:${state.id}`, state.id, JSON.stringify(state.viewport)])
|
||||||
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
|
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
|
||||||
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
|
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
|
||||||
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
|
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
|
||||||
@@ -184,7 +188,7 @@ async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express()
|
export const app = express()
|
||||||
app.disable('x-powered-by')
|
app.disable('x-powered-by')
|
||||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
|
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
|
||||||
app.use(express.json({ limit: '2mb' }))
|
app.use(express.json({ limit: '2mb' }))
|
||||||
@@ -285,6 +289,9 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex
|
|||||||
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
|
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
|
||||||
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
|
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
|
||||||
const port = Number(process.env.PORT || 8787)
|
const port = Number(process.env.PORT || 8787)
|
||||||
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
||||||
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
|
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
|
||||||
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown)
|
if (!process.env.VITEST) {
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
}
|
||||||
|
|||||||
+2
-40
@@ -1,11 +1,8 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import { createHash } from 'node:crypto'
|
|
||||||
import fs from 'node:fs/promises'
|
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import pg from 'pg'
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
const { Client } = pg
|
|
||||||
const databaseUrl = process.env.DATABASE_URL
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
console.error('DATABASE_URL is required')
|
console.error('DATABASE_URL is required')
|
||||||
@@ -13,39 +10,4 @@ if (!databaseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
const client = new Client({ connectionString: databaseUrl })
|
await runMigrations(databaseUrl, migrationsDir)
|
||||||
await client.connect()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
|
|
||||||
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
checksum TEXT NOT NULL,
|
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)`)
|
|
||||||
|
|
||||||
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
|
|
||||||
for (const name of files) {
|
|
||||||
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
|
|
||||||
const checksum = createHash('sha256').update(sql).digest('hex')
|
|
||||||
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
|
|
||||||
if (existing.rows[0]) {
|
|
||||||
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
|
|
||||||
console.log(`skip ${name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
await client.query('BEGIN')
|
|
||||||
try {
|
|
||||||
await client.query(sql)
|
|
||||||
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
|
|
||||||
await client.query('COMMIT')
|
|
||||||
console.log(`apply ${name}`)
|
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK')
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('OSINT migrations complete.')
|
|
||||||
} finally {
|
|
||||||
await client.end()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import pg from 'pg'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
|
const { Client } = pg
|
||||||
|
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||||
|
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||||
|
const databaseName = `osint_test_${process.pid}_${Date.now()}`
|
||||||
|
let adminClient: InstanceType<typeof Client>
|
||||||
|
let testDatabaseUrl = ''
|
||||||
|
|
||||||
|
suite('PostgreSQL migrations', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const adminUrl = new URL(baseDatabaseUrl!)
|
||||||
|
adminUrl.pathname = '/postgres'
|
||||||
|
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||||
|
await adminClient.connect()
|
||||||
|
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||||
|
const testUrl = new URL(baseDatabaseUrl!)
|
||||||
|
testUrl.pathname = `/${databaseName}`
|
||||||
|
testDatabaseUrl = testUrl.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!adminClient) return
|
||||||
|
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
|
||||||
|
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||||
|
await adminClient.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies every migration transactionally and is idempotent', async () => {
|
||||||
|
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(5)
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: testDatabaseUrl })
|
||||||
|
await client.connect()
|
||||||
|
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
|
||||||
|
const tableNames = tables.rows.map(row => row.table_name)
|
||||||
|
expect(tableNames).toEqual(expect.arrayContaining(['levels', 'widgets', 'widget_relations', 'playthroughs', 'assets', 'schema_migrations']))
|
||||||
|
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||||
|
expect(ledger.rows[0].count).toBe('5')
|
||||||
|
await client.end()
|
||||||
|
|
||||||
|
const secondRun: string[] = []
|
||||||
|
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||||
|
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(5)
|
||||||
|
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Client } = pg
|
||||||
|
|
||||||
|
export async function runMigrations(databaseUrl: string, migrationsDir: string, log: (message: string) => void = console.log) {
|
||||||
|
const client = new Client({ connectionString: databaseUrl })
|
||||||
|
await client.connect()
|
||||||
|
try {
|
||||||
|
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
|
||||||
|
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`)
|
||||||
|
|
||||||
|
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
|
||||||
|
for (const name of files) {
|
||||||
|
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
|
||||||
|
const checksum = createHash('sha256').update(sql).digest('hex')
|
||||||
|
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
|
||||||
|
log(`skip ${name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await client.query('BEGIN')
|
||||||
|
try {
|
||||||
|
await client.query(sql)
|
||||||
|
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
log(`apply ${name}`)
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('OSINT migrations complete.')
|
||||||
|
} finally {
|
||||||
|
await client.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-41
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
|
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
|
||||||
|
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
const BOARD_H = 1500
|
const BOARD_H = 1500
|
||||||
@@ -17,30 +18,6 @@ function connectionPoint(item: Evidence) {
|
|||||||
|
|
||||||
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
|
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
|
||||||
|
|
||||||
function normalizeCase(state: CaseState): CaseState {
|
|
||||||
const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
|
|
||||||
const membership = new Map<string, string[]>()
|
|
||||||
for (const relation of relations.filter(relation => relation.type === 'contains').sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) membership.set(relation.fromWidgetId, [...(membership.get(relation.fromWidgetId) || []), relation.toWidgetId])
|
|
||||||
return { ...state, relations,
|
|
||||||
documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {} })),
|
|
||||||
evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: membership.get(widget.id) || [] })) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function containedIds(state: CaseState, widgetId: string) {
|
|
||||||
return state.relations.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map(relation => relation.toWidgetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderIsOpen(folder: Evidence) { return folder.config?.open === true }
|
|
||||||
function relationPosition(state: CaseState, relation: WidgetRelation) {
|
|
||||||
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
|
||||||
const order = relation.sortOrder || 0
|
|
||||||
const configuredX = Number(relation.config?.x), configuredY = Number(relation.config?.y)
|
|
||||||
return {
|
|
||||||
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
|
||||||
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
||||||
const [noLevels, setNoLevels] = useState(false)
|
const [noLevels, setNoLevels] = useState(false)
|
||||||
@@ -214,9 +191,9 @@ export function App() {
|
|||||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||||
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
||||||
<span />
|
<span />
|
||||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||||
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
||||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.min(1.5, s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||||
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
|
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
|
||||||
</div>
|
</div>
|
||||||
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
||||||
@@ -259,7 +236,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
|||||||
if (!event.ctrlKey && !event.metaKey) return
|
if (!event.ctrlKey && !event.metaKey) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, Math.min(1.5, s.viewport.zoom - event.deltaY * .006)) } }))
|
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
|
||||||
}
|
}
|
||||||
board.addEventListener('wheel', handlePinch, { passive: false })
|
board.addEventListener('wheel', handlePinch, { passive: false })
|
||||||
return () => board.removeEventListener('wheel', handlePinch)
|
return () => board.removeEventListener('wheel', handlePinch)
|
||||||
@@ -276,9 +253,9 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
|||||||
if (!drag.current) return
|
if (!drag.current) return
|
||||||
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
|
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
|
||||||
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
|
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
|
||||||
if (drag.current.kind === 'widget') update(s => ({ ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } : e) }))
|
if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
||||||
else if (drag.current.kind === 'relation') update(s => ({ ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } } : relation) }))
|
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
|
||||||
else update(s => ({ ...s, viewport: { ...s.viewport, x: drag.current!.originX + dx, y: drag.current!.originY + dy } }))
|
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||||
}
|
}
|
||||||
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
|
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
|
||||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||||
@@ -350,19 +327,10 @@ 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>
|
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 dateValue(date: string) {
|
|
||||||
const parsed = Date.parse(date)
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
|
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
|
||||||
const itemYears = items.map(item => Number(item.date.slice(0, 4))).filter(Number.isFinite)
|
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date))
|
||||||
let startYear = itemYears.length ? Math.min(...itemYears) : new Date().getFullYear() - 2
|
|
||||||
let endYear = itemYears.length ? Math.max(...itemYears) : startYear + 4
|
|
||||||
if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear); startYear -= Math.floor(missing / 2); endYear += Math.ceil(missing / 2) }
|
|
||||||
const start = Date.UTC(startYear, 0, 1), end = Date.UTC(endYear, 11, 31)
|
|
||||||
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
|
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
|
||||||
const position = (date: string) => Math.max(0, Math.min(100, ((dateValue(date) - start) / (end - start)) * 100))
|
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>
|
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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { CaseState, Evidence, WidgetRelation } from './types'
|
||||||
|
import {
|
||||||
|
MAX_BOARD_ZOOM,
|
||||||
|
MIN_BOARD_ZOOM,
|
||||||
|
clampBoardZoom,
|
||||||
|
containedIds,
|
||||||
|
folderIsOpen,
|
||||||
|
moveBoardPoint,
|
||||||
|
normalizeCase,
|
||||||
|
panViewport,
|
||||||
|
relationPosition,
|
||||||
|
timelinePositionPercent,
|
||||||
|
timelineRange,
|
||||||
|
zoomFromWheel,
|
||||||
|
} from './boardDomain'
|
||||||
|
|
||||||
|
const folder: Evidence = {
|
||||||
|
id: 'folder-1',
|
||||||
|
type: 'folder',
|
||||||
|
title: 'Folder',
|
||||||
|
content: '',
|
||||||
|
x: 200,
|
||||||
|
y: 300,
|
||||||
|
width: 260,
|
||||||
|
config: { open: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: CaseState = {
|
||||||
|
id: 'test-level',
|
||||||
|
title: 'Test',
|
||||||
|
subtitle: '',
|
||||||
|
documents: [],
|
||||||
|
evidence: [folder],
|
||||||
|
relations: [],
|
||||||
|
connections: [],
|
||||||
|
viewport: { x: 10, y: 20, zoom: 0.5 },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('board coordinate math', () => {
|
||||||
|
it('moves board objects by screen distance divided by zoom', () => {
|
||||||
|
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 0.5)).toEqual({ x: 200, y: 30 })
|
||||||
|
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 1.25)).toEqual({ x: 140, y: 60 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid zoom during coordinate conversion', () => {
|
||||||
|
expect(() => moveBoardPoint({ x: 0, y: 0 }, { x: 10, y: 10 }, 0)).toThrow(RangeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pans in screen coordinates without scaling the delta', () => {
|
||||||
|
expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps toolbar and pinch zoom to the same limits', () => {
|
||||||
|
expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
|
||||||
|
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
|
||||||
|
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
|
||||||
|
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('timeline projection', () => {
|
||||||
|
it('creates at least a four-year range around a single year', () => {
|
||||||
|
const range = timelineRange(['2021-03-12'])
|
||||||
|
expect(range.endYear - range.startYear).toBe(4)
|
||||||
|
expect(range.startYear).toBeLessThanOrEqual(2021)
|
||||||
|
expect(range.endYear).toBeGreaterThanOrEqual(2021)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses all supplied years when the evidence spans a wider range', () => {
|
||||||
|
expect(timelineRange(['2012-01-01', '2024-06-01'])).toMatchObject({ startYear: 2012, endYear: 2024 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects dates proportionally and clamps dates outside the range', () => {
|
||||||
|
const range = timelineRange(['2020-01-01', '2024-12-31'])
|
||||||
|
expect(timelinePositionPercent('2010-01-01', range)).toBe(0)
|
||||||
|
expect(timelinePositionPercent('2030-01-01', range)).toBe(100)
|
||||||
|
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
|
||||||
|
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('folder domain behavior', () => {
|
||||||
|
const relations: WidgetRelation[] = [
|
||||||
|
{ id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 },
|
||||||
|
{ id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 },
|
||||||
|
{ id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', type: 'contains', sortOrder: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('orders and scopes contained documents by their normalized relations', () => {
|
||||||
|
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only treats an explicit boolean true as open', () => {
|
||||||
|
expect(folderIsOpen(folder)).toBe(false)
|
||||||
|
expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true)
|
||||||
|
expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains a configured expanded file position', () => {
|
||||||
|
const relation = { ...relations[0], config: { x: 720, y: 415 } }
|
||||||
|
expect(relationPosition({ ...state, relations }, relation)).toEqual({ x: 720, y: 415 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives a deterministic position when a relation has not been moved', () => {
|
||||||
|
expect(relationPosition({ ...state, relations }, relations[2])).toEqual({ x: 550, y: 270 })
|
||||||
|
expect(relationPosition({ ...state, relations }, relations[0])).toEqual({ x: 960, y: 270 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes legacy containment without changing source ownership', () => {
|
||||||
|
const legacy = {
|
||||||
|
...state,
|
||||||
|
documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }],
|
||||||
|
evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
|
||||||
|
relations: undefined,
|
||||||
|
} as unknown as CaseState
|
||||||
|
const normalized = normalizeCase(legacy)
|
||||||
|
expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] })
|
||||||
|
expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} })
|
||||||
|
expect(normalized.relations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { CaseState, Evidence, Viewport, WidgetRelation } from './types'
|
||||||
|
|
||||||
|
export const MIN_BOARD_ZOOM = 0.45
|
||||||
|
export const MAX_BOARD_ZOOM = 1.5
|
||||||
|
|
||||||
|
export function clampBoardZoom(zoom: number) {
|
||||||
|
return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomFromWheel(currentZoom: number, deltaY: number) {
|
||||||
|
return clampBoardZoom(currentZoom - deltaY * 0.006)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
|
||||||
|
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||||
|
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
||||||
|
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateValue(date: string) {
|
||||||
|
const parsed = Date.parse(date)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear()) {
|
||||||
|
const years = dates
|
||||||
|
.map(date => Number(date.slice(0, 4)))
|
||||||
|
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
|
||||||
|
let startYear = years.length ? Math.min(...years) : fallbackYear - 2
|
||||||
|
let endYear = years.length ? Math.max(...years) : startYear + 4
|
||||||
|
if (endYear - startYear < 4) {
|
||||||
|
const missing = 4 - (endYear - startYear)
|
||||||
|
startYear -= Math.floor(missing / 2)
|
||||||
|
endYear += Math.ceil(missing / 2)
|
||||||
|
}
|
||||||
|
return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelinePositionPercent(date: string, range: Pick<ReturnType<typeof timelineRange>, 'start' | 'end'>) {
|
||||||
|
if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration')
|
||||||
|
return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containedIds(state: CaseState, widgetId: string) {
|
||||||
|
return state.relations
|
||||||
|
.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId)
|
||||||
|
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||||
|
.map(relation => relation.toWidgetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderIsOpen(folder: Evidence) {
|
||||||
|
return folder.config?.open === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationPosition(state: CaseState, relation: WidgetRelation) {
|
||||||
|
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
||||||
|
const order = relation.sortOrder || 0
|
||||||
|
const configuredX = Number(relation.config?.x)
|
||||||
|
const configuredY = Number(relation.config?.y)
|
||||||
|
return {
|
||||||
|
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
||||||
|
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCase(state: CaseState): CaseState {
|
||||||
|
const relations = Array.isArray(state.relations)
|
||||||
|
? state.relations
|
||||||
|
: state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({
|
||||||
|
id: `contains:${widget.id}:${documentId}`,
|
||||||
|
fromWidgetId: widget.id,
|
||||||
|
toWidgetId: documentId,
|
||||||
|
type: 'contains',
|
||||||
|
sortOrder: index,
|
||||||
|
})))
|
||||||
|
const normalized = { ...state, relations }
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
documents: state.documents.map(document => ({
|
||||||
|
...document,
|
||||||
|
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
|
||||||
|
metadata: document.metadata || {},
|
||||||
|
})),
|
||||||
|
evidence: state.evidence.map(widget => ({
|
||||||
|
...widget,
|
||||||
|
type: widget.type === 'evidence' ? 'folder' : widget.type,
|
||||||
|
config: widget.config || {},
|
||||||
|
containedDocumentIds: containedIds(normalized, widget.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user