Allow undated reconstructed events

This commit is contained in:
2026-08-14 19:57:22 +02:00
parent 703b9652c9
commit d99deb3c06
10 changed files with 60 additions and 17 deletions
+26 -8
View File
@@ -40,6 +40,7 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
@@ -143,9 +144,8 @@ export function App() {
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
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); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id)
supportingEvidenceIds: [], ...position, width: 270 }
setNewEventDraft(event)
}
const addParty = () => {
@@ -315,7 +315,11 @@ export function App() {
...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
].sort((a, b) => dateValue(a.date) - dateValue(b.date))
const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!))
const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => {
if (!a.eventDate) return b.eventDate ? 1 : 0
if (!b.eventDate) return -1
return dateValue(a.eventDate) - dateValue(b.eventDate)
})
return <main className="desktop">
<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>
@@ -355,7 +359,7 @@ export function App() {
onLocate={focusEvidence}
onEditParty={setEditingPartyId}
/>}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
@@ -388,6 +392,20 @@ export function App() {
onClose={() => setEditingEventId(null)}
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
/>}
{newEventDraft && <EventEditor
key={newEventDraft.id}
event={newEventDraft}
evidence={caseState.evidence}
documents={caseState.documents}
onClose={() => setNewEventDraft(null)}
onSave={event => {
update(state => ({ ...state, evidence: [...state.evidence, event] }))
setNewEventDraft(null)
setSelected(event.id)
setRecentlyCreatedExhibitId(event.id)
setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED')
}}
/>}
{editingPartyId && <PartyEditor
key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!}
@@ -840,7 +858,7 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
const submit = (submitEvent: React.FormEvent) => {
submitEvent.preventDefault()
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : new Date().toISOString()
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
}
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
@@ -849,13 +867,13 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
<small>EVENT EXHIBIT · THIS HAPPENED</small>
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
<label className="field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL</span><input aria-label="Occurred at" type="datetime-local" required value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/></label>
<label className="field event-date-field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL · OPTIONAL</span><span><input aria-label="Occurred at" type="datetime-local" value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/><button type="button" disabled={!occurredAt} onClick={() => setOccurredAt('')}>CLEAR DATE</button></span></label>
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
<div className="folder-members event-support-list">
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
</div>
<p className="folder-editor-note">An event is an investigator assertion. Its occurrence time is separate from the publication time of the documents supporting it.</p>
<p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
</div>
</form></div>
+1 -1
View File
@@ -37,7 +37,7 @@ function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
const supportCount = exhibit.supportingEvidenceIds?.length || 0
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{exhibit.eventDate && <time>{new Date(exhibit.eventDate).toLocaleString()}</time>}
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div>
</div>
}
+1
View File
@@ -234,6 +234,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
.event-date-field > span:last-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }.event-date-field > span:last-child button { border: 1px outset #8c948e; background: #c8cbc4; color: #4d5853; padding: 0 10px; cursor: pointer; font: 8px IBM Plex Mono; }.event-date-field > span:last-child button:disabled { opacity: .45; cursor: default; }
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
.folder-member.included { background: #e2d7bd; opacity: 1; }
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }