feat(timeline): add live filter, actor chips, group headers, copy button, source toggle, count badge

- Live search/filter bar: filters events by keyword across event, actor, source_excerpt, date
- Actor filter chips: click to filter by actor, multi-select, teal active state
- Year/month group headers when sorted chronologically (── 2023 ──, Mar 2024 ──)
- Per-event copy button (hover-revealed 📋): copies "date · actor · event" to clipboard
- "Hide/show sources" toggle: collapses all source excerpts without re-rendering
- Count badge: "23 events · 3 actors · 2022–2025" above the list
- applyTimelineFilters() unifies sort + actor + text filters in one re-render pass
- CSV export now includes end_date column
- Reset all filter state on each new run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:46:59 +02:00
parent 59b39ff85b
commit ffcf887428
7 changed files with 807 additions and 49 deletions
+137 -17
View File
@@ -379,6 +379,10 @@ const TIMELINE_I18N = {
let lastTimelineEvents = [];
let lastTimelineEventsOriginal = [];
let activeActorFilters = new Set();
let timelineSearchTerm = '';
let showSources = true;
let timelineSortMode = 'doc';
let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}]
let lastTranscriptData = null;
let lastRedactedText = null;
@@ -942,6 +946,26 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.closest('#rdlCopy')) copyRedactedText();
if (e.target.closest('#rdlTxt')) downloadRedactedTxt();
if (e.target.closest('#rdlDocx')) downloadRedactedDocx();
const copyBtn = e.target.closest('.timeline-copy-btn');
if (copyBtn) {
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
const orig = copyBtn.innerHTML;
copyBtn.innerHTML = '&#10003;';
setTimeout(() => { copyBtn.innerHTML = orig; }, 1200);
}).catch(() => {});
}
const chip = e.target.closest('.timeline-actor-chip');
if (chip) {
const actor = chip.dataset.actor;
if (activeActorFilters.has(actor)) {
activeActorFilters.delete(actor);
chip.classList.remove('is-active');
} else {
activeActorFilters.add(actor);
chip.classList.add('is-active');
}
applyTimelineFilters();
}
});
const activeTool = document.body.dataset.activeTool || state.activeTool;
if (els.form && tools[activeTool]) {
@@ -1323,16 +1347,33 @@ function renderResults(data) {
const sortChr = document.getElementById('sortChronological');
if (sortDoc && sortChr) {
sortDoc.addEventListener('click', () => {
lastTimelineEvents = [...lastTimelineEventsOriginal];
document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents);
timelineSortMode = 'doc';
sortDoc.classList.add('is-active');
sortChr.classList.remove('is-active');
applyTimelineFilters();
});
sortChr.addEventListener('click', () => {
lastTimelineEvents = sortChronological(lastTimelineEventsOriginal);
document.getElementById('timelineListContainer').innerHTML = renderTimeline(lastTimelineEvents);
timelineSortMode = 'chrono';
sortChr.classList.add('is-active');
sortDoc.classList.remove('is-active');
applyTimelineFilters();
});
}
const searchInput = document.getElementById('timelineSearch');
if (searchInput) {
searchInput.addEventListener('input', () => {
timelineSearchTerm = searchInput.value.trim().toLowerCase();
applyTimelineFilters();
});
}
const sourceToggle = document.getElementById('sourceToggle');
if (sourceToggle) {
sourceToggle.addEventListener('click', () => {
showSources = !showSources;
sourceToggle.textContent = showSources ? 'Hide sources' : 'Show sources';
document.querySelectorAll('.timeline-excerpt').forEach((el) => {
el.classList.toggle('is-hidden', !showSources);
});
});
}
}
@@ -1467,17 +1508,24 @@ function renderMainFinding(data) {
if (data.tool === 'timeline') {
lastTimelineEventsOriginal = data.events || [];
lastTimelineEvents = [...lastTimelineEventsOriginal];
activeActorFilters = new Set();
timelineSearchTerm = '';
showSources = true;
timelineSortMode = 'doc';
const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV';
const csvBtn = lastTimelineEvents.length
const csvBtn = lastTimelineEventsOriginal.length
? `<div class="timeline-export"><button type="button" id="exportCsvBtn" class="export-csv-btn">${escapeHtml(csvLabel)}</button></div>`
: '';
const sortBar = lastTimelineEvents.length > 1 ? `
const countBadge = buildTimelineCountBadge(lastTimelineEventsOriginal);
const actorChips = buildActorChips(lastTimelineEventsOriginal);
const toolbar = buildTimelineToolbar();
const sortBar = lastTimelineEventsOriginal.length > 1 ? `
<div class="timeline-sort-bar">
<span class="sort-label">Sort:</span>
<button type="button" class="sort-btn is-active" id="sortDocOrder">${escapeHtml(currentTimelineT('sortDocOrder') || 'Document order')}</button>
<button type="button" class="sort-btn" id="sortChronological">${escapeHtml(currentTimelineT('sortChronological') || 'Chronological')}</button>
</div>` : '';
return `<p>${escapeHtml(data.what_we_found || '')}</p>${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents)}</div>${csvBtn}`;
return `<p>${escapeHtml(data.what_we_found || '')}</p>${countBadge}${actorChips}${toolbar}${sortBar}<div id="timelineListContainer">${renderTimeline(lastTimelineEvents, false)}</div>${csvBtn}`;
}
if (data.tool === 'summarize') {
return [
@@ -1533,24 +1581,96 @@ function renderEvidenceItem(item) {
`;
}
function renderTimeline(events) {
if (!events.length) {
return '<p>No events were identified.</p>';
function buildTimelineCountBadge(events) {
if (!events.length) return '';
const actors = new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'));
const isoDates = events.map((e) => e.date || '').filter((d) => /^\d{4}/.test(d)).sort();
let rangeStr = '';
if (isoDates.length) {
const y0 = isoDates[0].slice(0, 4);
const y1 = isoDates[isoDates.length - 1].slice(0, 4);
rangeStr = y0 === y1 ? ` · ${y0}` : ` · ${y0}${y1}`;
}
return `<ol class="timeline-list">${events.map((ev) => {
const ac = actors.size;
return `<p class="timeline-count-badge">${events.length} event${events.length !== 1 ? 's' : ''}${ac ? ` · ${ac} actor${ac !== 1 ? 's' : ''}` : ''}${rangeStr}</p>`;
}
function buildActorChips(events) {
const actors = [...new Set(events.map((e) => e.actor).filter((a) => a && a !== 'unknown'))].sort();
if (actors.length < 2) return '';
const chips = actors.map((a) =>
`<button type="button" class="timeline-actor-chip" data-actor="${escapeHtml(a)}">${escapeHtml(a)}</button>`
).join('');
return `<div class="timeline-actor-chips" id="actorChips">${chips}</div>`;
}
function buildTimelineToolbar() {
return `<div class="timeline-toolbar">
<input type="search" id="timelineSearch" class="timeline-search" placeholder="Search events…" aria-label="Filter events by keyword">
<button type="button" id="sourceToggle" class="source-toggle-btn">Hide sources</button>
</div>`;
}
function applyTimelineFilters() {
let events = timelineSortMode === 'chrono'
? sortChronological([...lastTimelineEventsOriginal])
: [...lastTimelineEventsOriginal];
if (activeActorFilters.size > 0) {
events = events.filter((e) => activeActorFilters.has(e.actor));
}
if (timelineSearchTerm) {
const q = timelineSearchTerm;
events = events.filter((e) =>
(e.event || '').toLowerCase().includes(q) ||
(e.actor || '').toLowerCase().includes(q) ||
(e.source_excerpt || '').toLowerCase().includes(q) ||
(e.date || '').toLowerCase().includes(q)
);
}
lastTimelineEvents = events;
const container = document.getElementById('timelineListContainer');
if (container) container.innerHTML = renderTimeline(events, timelineSortMode === 'chrono');
}
function renderTimeline(events, grouped = false) {
if (!events.length) {
return '<p class="timeline-empty">No matching events.</p>';
}
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let lastGroupKey = null;
const items = events.map((ev) => {
const conf = ev.confidence || 'medium';
return `
<li class="timeline-item confidence-${escapeHtml(conf)}">
let groupHeader = '';
if (grouped && /^\d{4}-\d{2}/.test(ev.date || '')) {
const year = ev.date.slice(0, 4);
const mon = ev.date.slice(5, 7);
const key = `${year}-${mon}`;
if (key !== lastGroupKey) {
const prevYear = lastGroupKey ? lastGroupKey.slice(0, 4) : null;
const label = (year !== prevYear)
? year
: `${MONTH_NAMES[parseInt(mon, 10) - 1] || mon} ${year}`;
groupHeader = `<li class="timeline-group-header" role="presentation"><span>${escapeHtml(label)}</span></li>`;
lastGroupKey = key;
}
}
const excerptHtml = ev.source_excerpt
? `<small class="timeline-excerpt${showSources ? '' : ' is-hidden'}">${escapeHtml(ev.source_excerpt)}</small>`
: '';
const copyText = [ev.date, ev.actor, ev.event].filter(Boolean).join(' · ');
return `${groupHeader}<li class="timeline-item confidence-${escapeHtml(conf)}">
<div class="timeline-header">
<strong class="timeline-date">${escapeHtml(ev.date || 'unknown')}${ev.time ? `<span class="timeline-time"> ${escapeHtml(ev.time)}</span>` : ''}</strong>
${ev.date_type ? `<span class="date-type-badge">${escapeHtml(ev.date_type)}</span>` : ''}
<span class="confidence-badge confidence-badge--${escapeHtml(conf)}">${escapeHtml(conf)}</span>
<button type="button" class="timeline-copy-btn" data-copy="${escapeHtml(copyText)}" title="Copy event" aria-label="Copy event to clipboard">&#128203;</button>
</div>
<span class="timeline-actor">${escapeHtml(ev.actor || 'unknown actor')}</span>
<p class="timeline-event">${escapeHtml(ev.event || '')}</p>
${ev.source_excerpt ? `<small class="timeline-excerpt">${escapeHtml(ev.source_excerpt)}</small>` : ''}
${excerptHtml}
</li>`;
}).join('')}</ol>`;
}).join('');
return `<ol class="timeline-list">${items}</ol>`;
}
function renderFeedbackWidget() {
@@ -1620,9 +1740,9 @@ function setupFeedbackWidget(tool) {
}
function exportTimelineCSV(events) {
const header = ['Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
const header = ['Date', 'End Date', 'Time', 'Date Type', 'Actor', 'Event', 'Source Excerpt', 'Confidence'];
const rows = events.map((ev) => [
ev.date || '', ev.time || '', ev.date_type || '', ev.actor || '',
ev.date || '', ev.end_date || '', ev.time || '', ev.date_type || '', ev.actor || '',
ev.event || '', ev.source_excerpt || '', ev.confidence || '',
]);
const csv = [header, ...rows]