diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 08c1dbd..2cfbbca 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -40,7 +40,7 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, expect(await page.locator('.timeline-range').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11) await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/) await expect(page.locator('.brief-panel')).toBeVisible() - await expect(page.getByRole('button', { name: 'Brief', exact: true })).toContainText('2') + await expect(page.getByRole('button', { name: 'Brief', exact: true }).locator('.brief-count')).toHaveCount(0) await page.getByRole('button', { name: 'Minimize brief', exact: true }).click() await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/) await expect(page.locator('.brief-panel > p')).toBeHidden() diff --git a/e2e/scene7.acceptance.spec.ts b/e2e/scene7.acceptance.spec.ts index 95ed0a6..553ab1c 100644 --- a/e2e/scene7.acceptance.spec.ts +++ b/e2e/scene7.acceptance.spec.ts @@ -18,8 +18,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene if (pageErrors.length) throw new Error(`Scene 7 failed to render: ${pageErrors.map(error => error.message).join('; ')}`) await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible() await expect(page.locator('.brief-panel')).toBeVisible() - await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor') - await expect(page.locator('.brief-goals section')).toHaveClass(/\bpending\b/) + await expect(page.locator('.brief-panel > p')).toContainText('Prove that Nils Aall Barricelli was an inventor') + await expect(page.locator('.brief-goals')).toHaveCount(0) await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click() const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST' diff --git a/src/App.tsx b/src/App.tsx index b7a2c31..ac5c85d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { audio } from './audio' -import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' +import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' @@ -23,6 +23,7 @@ function screenshotFile(file: File, index: number) { return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() }) } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } +function reportAcknowledgementKey(levelId: string) { return `gupi-osint-board:report-acknowledged:${levelId}` } function documentSearchText(document: CaseDocument) { return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType, ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]), @@ -74,6 +75,8 @@ export function App() { const [newPartyDraft, setNewPartyDraft] = useState(null) const [briefOpen, setBriefOpen] = useState(false) const [reportOpen, setReportOpen] = useState(false) + const [briefAcknowledged, setBriefAcknowledged] = useState(false) + const [reportAcknowledged, setReportAcknowledged] = useState(false) const [playerName, setPlayerName] = useState('Player') const [editingBrief, setEditingBrief] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false) @@ -100,6 +103,8 @@ export function App() { if (!response.ok) throw new Error('Level unavailable') const data = normalizeCase(await response.json()) setCaseState(data) + setBriefAcknowledged(Boolean(localStorage.getItem(briefAcknowledgementKey(data.id)))) + setReportAcknowledged(Boolean(localStorage.getItem(reportAcknowledgementKey(data.id)))) const arrivals = data.newlyVisibleDocumentIds || [] if (arrivals.length) { setArrivingExhibitIds(arrivals) @@ -181,6 +186,18 @@ export function App() { return () => window.clearTimeout(timer) }, [arrivingExhibitIds]) + useEffect(() => { + if (!caseState) return + if (briefOpen && !briefAcknowledged) { + localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString()) + setBriefAcknowledged(true) + } + if (reportOpen && !reportAcknowledged) { + localStorage.setItem(reportAcknowledgementKey(caseState.id), new Date().toISOString()) + setReportAcknowledged(true) + } + }, [briefOpen, reportOpen, briefAcknowledged, reportAcknowledged, caseState]) + useLayoutEffect(() => { if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return const board = boardRef.current @@ -300,7 +317,10 @@ export function App() { } const closeBrief = () => { - if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString()) + if (caseState) { + localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString()) + setBriefAcknowledged(true) + } setBriefOpen(false) } @@ -574,8 +594,8 @@ export function App() { const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length - const briefAttentionCount = unresolvedConceptCount + pendingGoalCount - const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0 + const briefAttentionCount = !briefAcknowledged ? unresolvedConceptCount + pendingGoalCount : 0 + const reportAttentionCount = !reportAcknowledged && caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0 const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => { const widget=exhibitWidget(exhibit.type) @@ -990,6 +1010,9 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx {previewOrigin && threadPointer && } {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return })} + {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} @@ -1243,13 +1266,20 @@ function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const pending = goals.filter(goal => goal.status === 'pending').length const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION' + const authoredBody = brief.body.trim() + const [firstLine = '', ...remainingLines] = authoredBody.split(/\r?\n/) + const startsWithDisplayHeading = remainingLines.some(line => line.trim()) && firstLine.length < 70 && firstLine === firstLine.toLocaleUpperCase() + const assignment = (startsWithDisplayHeading ? remainingLines.join('\n').trim() : authoredBody) + || goals.map(goal => [goal.title, goal.instructions].filter(Boolean).join('\n')).join('\n\n') + || 'No brief has been authored yet.' + const checklist = brief.checklist || [] return } @@ -1270,7 +1300,7 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: ( const [body, setBody] = useState(brief.body) const [concepts, setConcepts] = useState(brief.concepts) const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }]) - return
{ submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}> + return
{ submit.preventDefault(); onSave({ ...brief, body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
Edit level brief
AUTHORING · PLAYER CONCEPTS