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:
+137
-17
@@ -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 = '✓';
|
||||
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">📋</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]
|
||||
|
||||
Reference in New Issue
Block a user