diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index d18c58a..ac545d1 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -27,6 +27,9 @@ async function waitForSave(page: Page, action: () => Promise) { test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => { await page.goto('/?level=e2e-level&edit=1') await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() + await expect(page.locator('.brief-panel')).toBeVisible() + await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2') + await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click() const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]') const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]') @@ -102,22 +105,28 @@ test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and re await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y }) await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y }) - await page.getByRole('button', { name: 'BRIEF', exact: true }).click() + await page.getByRole('button', { name: 'Case brief', exact: true }).click() await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace') const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' }) - await personConcept.getByRole('button', { name: 'PERSON', exact: true }).click() + await waitForSave(page, () => personConcept.getByRole('button', { name: 'PERSON', exact: true }).click()) + await expect(page.locator('.brief-panel')).toBeVisible() + await expect(page.locator('.evidence-card.party.arriving')).toContainText('Ada Lovelace') + await personConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click() await expect(page.getByText('Edit person dossier')).toBeVisible() await page.getByLabel('Party aliases').fill('A. A. L.') await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check() await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) await expect(page.locator('.evidence-card.party')).toContainText('Ada Lovelace') - await page.getByRole('button', { name: 'BRIEF', exact: true }).click() const organizationConcept = page.locator('.brief-concepts section').filter({ hasText: 'Difference Engine Bureau' }) - await organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click() + await waitForSave(page, () => organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click()) + await expect(page.locator('.brief-panel')).toBeVisible() + await organizationConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click() await page.getByLabel('Organization type').selectOption('public_body') await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) + await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click() await page.reload() + await expect(page.locator('.brief-panel')).toBeHidden() await expect(page.locator('.evidence-card.party')).toHaveCount(2) await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau']) diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 10603bb..cd69b88 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -7,9 +7,12 @@ async function waitForSave(page: Page, action: () => Promise) { } async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) { - await page.getByRole('button', { name: 'BRIEF', exact: true }).click() + if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click() const concept = page.locator('.brief-concepts section').filter({ hasText: name }) - await concept.getByRole('button', { name: kind, exact: true }).click() + await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click()) + await expect(page.locator('.brief-panel')).toBeVisible() + await expect(concept).toHaveClass(/\bjust-resolved\b/) + await concept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click() await page.getByLabel('Party summary').fill(summary) await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click()) } @@ -52,6 +55,7 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await classify(page, 'Calder Optical Works', 'ORGANIZATION', 'Supplier that released sealed crate CO-771 to the approved carrier.') await classify(page, 'Harbor & Fell Logistics', 'ORGANIZATION', 'Approved carrier whose vehicle delivered the crate to the wrong address.') await classify(page, 'Voss Antiquities Ltd', 'ORGANIZATION', 'Mara Voss’s company, based at Warehouse 3 and named as auction consignor.') + await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click() await createEvent(page, 'CO-771 left Calder for North Quay', 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.', '1987-10-16T16:40', ['Carrier Dispatch Manifest']) await createEvent(page, 'CO-771 was diverted to Warehouse 3', 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.', '1987-10-17T22:08', ['Old Glass Harbor Gate Ledger', 'Warehouse 3 Security Photograph', 'Delivery Redirection Memorandum']) diff --git a/src/App.tsx b/src/App.tsx index 7b1eefd..181c7d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ ].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label })) function uid(_prefix: string) { return crypto.randomUUID() } +function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function connectionPoint(item: Evidence) { return exhibitWidget(item.type).connectionPoint(item) } @@ -37,6 +38,7 @@ export function App() { const [briefOpen, setBriefOpen] = useState(false) const [editingBrief, setEditingBrief] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false) + const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState(null) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) @@ -53,8 +55,10 @@ export function App() { const editQuery = params.get('edit') === '1' ? '?edit=1' : '' const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`) if (!response.ok) throw new Error('Level unavailable') - const data = await response.json() - setCaseState(normalizeCase(data)); setStatus('EVIDENCE INTEGRITY: PROBABLY OK') + const data = normalizeCase(await response.json()) + setCaseState(data) + if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true) + setStatus('EVIDENCE INTEGRITY: PROBABLY OK') }) .catch(() => { const cached = localStorage.getItem('gupi-osint-board:last') @@ -66,6 +70,12 @@ export function App() { return () => clearInterval(timer) }, []) + useEffect(() => { + if (!recentlyCreatedExhibitId) return + const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400) + return () => window.clearTimeout(timer) + }, [recentlyCreatedExhibitId]) + const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current @@ -101,7 +111,7 @@ export function App() { x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260, } update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] })) - setOpenDoc(null); setSelected(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') + setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } const addNote = () => { @@ -110,7 +120,7 @@ export function App() { const { viewport } = caseState const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 } - update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id) + update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) } const addEvent = () => { @@ -120,7 +130,7 @@ export function App() { const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 } update(state => ({ ...state, evidence: [...state.evidence, event] })) - setSelected(event.id); setEditingEventId(event.id) + setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id) } const classifyConcept = (conceptId: string, partyKind: PartyKind) => { @@ -141,7 +151,14 @@ export function App() { evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party], brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) }, })) - setSelected(partyId); setBriefOpen(false); setEditingPartyId(partyId) + setSelected(partyId) + setRecentlyCreatedExhibitId(partyId) + setStatus(`${partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`) + } + + const closeBrief = () => { + if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString()) + setBriefOpen(false) } const handleCardClick = (id: string) => { @@ -219,6 +236,7 @@ export function App() { if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
const documentById = new Map(caseState.documents.map(document => [document.id, document])) + const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId)) const temporalItems: TemporalItem[] = [ ...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => { @@ -235,7 +253,7 @@ export function App() {
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
@@ -256,15 +274,17 @@ export function App() {
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
- setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> + setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} + recentlyCreatedExhibitId={recentlyCreatedExhibitId} canEdit={requestedEditMode && Boolean(caseState.editingAllowed)} - onClose={() => setBriefOpen(false)} + onClose={closeBrief} onEdit={() => setEditingBrief(true)} onClassify={classifyConcept} onLocate={focusEvidence} + onEditParty={setEditingPartyId} />} {storyEvents.length > 0 && } {!docsOpen && } @@ -348,7 +368,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { +function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) @@ -454,7 +474,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}> @@ -539,12 +559,14 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
} -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 }) { +function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { const partyById = new Map(parties.map(party => [party.id, party])) - return