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
+357 -15
View File
@@ -7,6 +7,27 @@
let uploadFiles = [];
let lastResult = null;
let branchContext = null;
let customSubQuestions = null;
const CACHE_KEY = 'dbn-advocate-last';
const ROLE_FLIP = {
'Biological mother': 'Child welfare services (Barnevernet)',
'Biological father': 'Child welfare services (Barnevernet)',
'Both biological parents': 'Child welfare services (Barnevernet)',
'Foster carer / long-term placement': 'Biological mother',
'Adoptive parent': 'Biological mother',
'Child (via representative)': 'Child welfare services (Barnevernet)',
'Extended family (grandparent, sibling, aunt/uncle)': 'Child welfare services (Barnevernet)',
'Child welfare services (Barnevernet)': 'Both biological parents',
};
const PARENT_ROLES = new Set([
'Biological mother', 'Biological father', 'Both biological parents',
'Foster carer / long-term placement', 'Adoptive parent',
'Child (via representative)', 'Extended family (grandparent, sibling, aunt/uncle)',
]);
let sliceHintShown = false;
let synthTimer = null;
let synthStartMs = null;
const SLICE_DEFS = [
{ id: 'family_core', label: 'Family Law Core' },
@@ -71,6 +92,12 @@
branchOrigin: document.getElementById('advBranchOrigin'),
branchSummary: document.getElementById('advBranchSummary'),
branchNotes: document.getElementById('advBranchNotes'),
inputCount: document.getElementById('advInputCount'),
previewAngles: document.getElementById('advPreviewAngles'),
subQPreview: document.getElementById('advSubQPreview'),
subQPreviewList: document.getElementById('advSubQPreviewList'),
runWithAngles: document.getElementById('advRunWithAngles'),
discardAngles: document.getElementById('advDiscardAngles'),
});
if (!els.form) return;
@@ -82,13 +109,21 @@
bindUpload();
bindModal();
bindBranch();
bindPreviewAngles();
els.form.addEventListener('submit', onSubmit);
els.results.addEventListener('click', (e) => {
const btn = e.target.closest('.dr-branch-btn');
const btn = e.target.closest('.dr-branch-btn, .dr-strength-branch-btn');
if (btn) branchFromSubQ(btn.dataset.question || '');
});
renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' })));
els.input.addEventListener('input', updateCharCount);
els.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); els.form.requestSubmit(); }
});
updateCharCount();
const cached = loadFromCache();
if (cached) showRestoreBanner(cached);
});
function bindRole() {
@@ -97,6 +132,13 @@
const isOther = els.roleSelect.value === '__other__';
els.roleCustom.classList.toggle('is-hidden', !isOther);
if (isOther) els.roleCustom.focus();
if (!sliceHintShown && PARENT_ROLES.has(els.roleSelect.value)) {
const echrBtn = els.slices.find((b) => b.dataset.slice === 'echr');
const ncBtn = els.slices.find((b) => b.dataset.slice === 'norwegian_courts');
const echrOff = echrBtn && !echrBtn.classList.contains('is-on');
const ncOff = ncBtn && !ncBtn.classList.contains('is-on');
if (echrOff || ncOff) { showSliceHint(echrBtn, ncBtn, echrOff, ncOff); sliceHintShown = true; }
}
});
}
@@ -354,6 +396,10 @@
payload.prior_context = branchContext;
payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim();
}
if (customSubQuestions) {
payload.sub_questions_override = customSubQuestions;
customSubQuestions = null;
}
const stepKeyToIndex = {
interpretation: 0,
@@ -456,6 +502,7 @@
els.runButton.disabled = false;
renderTrace(finalResult.trace || []);
renderResults(finalResult);
saveToCache(finalResult, { query, role: advocateRole, engine, slices, lang });
function handleStreamEvent(evt) {
if (!evt || !evt.event) return;
@@ -471,6 +518,19 @@
if (evt.event === 'step') {
const idx = stepKeyToIndex[evt.step];
if (idx === undefined) return;
if (evt.step === 'synthesis') {
if (evt.status === 'running') {
synthStartMs = Date.now();
synthTimer = setInterval(() => {
const elapsed = Math.round((Date.now() - synthStartMs) / 1000);
stepState[5] = { ...stepState[5], detail: `Synthesising… (${elapsed}s)` };
renderTrace(stepState);
}, 1000);
} else if (synthTimer) {
clearInterval(synthTimer);
synthTimer = null;
}
}
stepState[idx] = {
label: evt.label || stepState[idx].label,
detail: evt.detail || stepState[idx].detail,
@@ -522,27 +582,38 @@
}).join('');
}
function renderResults(data) {
function renderResults(data, restoreMode = false) {
const sources = data.sources || [];
const subs = data.sub_questions || [];
const role = data.advocate_role || '';
const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : [];
const weaknesses = Array.isArray(data.opposing_weaknesses) ? data.opposing_weaknesses : [];
// 1. Advocate banner
// 1. Advocate banner + flip bar
const flipRole = ROLE_FLIP[role] || '';
const bannerHtml = role ? `
<div class="adv-banner">
<span class="adv-banner__label">Representing</span>
<strong class="adv-banner__role">${escapeHtml(role)}</strong>
<span class="adv-banner__note">Brief argues for this party · grounded in Norwegian law and ECHR authorities</span>
</div>` : '';
</div>
${flipRole ? `<div class="adv-flip-bar">
<span class="adv-flip-bar__label">See the other side?</span>
<span class="adv-flip-bar__role">${escapeHtml(flipRole)}</span>
<button class="adv-flip-btn" type="button" data-flip-role="${escapeHtml(flipRole)}">Run counter-brief →</button>
</div>` : ''}` : '';
// 2. Client strengths
// 2. Client strengths — with ↗ deep-dive branch button per item
const strengthsHtml = strengths.length ? `
<div class="dr-result-block adv-strengths">
<h3 class="adv-strengths__head">Your strongest arguments</h3>
<ul class="adv-strengths__list">
${strengths.map((s) => `<li class="adv-strengths__item">${renderInlineCitations(escapeHtml(String(s)), sources)}</li>`).join('')}
${strengths.map((s) => `<li class="adv-strengths__item">
${renderInlineCitations(escapeHtml(String(s)), sources)}
<button class="dr-strength-branch-btn" type="button"
data-question="${escapeHtml('Deeper research: ' + String(s))}"
title="Branch research from this argument">↗</button>
</li>`).join('')}
</ul>
</div>` : '';
@@ -560,27 +631,27 @@
// 5. Sub-Q report cards
const subQReportsHtml = subs.length ? `
<div class="dr-result-block">
<div class="dr-sources-head">
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
<summary class="dr-collapsible__head">
<h3>What each sub-question agent researched</h3>
<small>${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}</small>
</div>
</summary>
<div class="dr-subq-list">
${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
</div>
</div>` : '';
</details>` : '';
// 6. Sources
const sourcesHtml = `
<div class="dr-result-block">
<div class="dr-sources-head">
<details class="dr-result-block dr-collapsible" ${restoreMode ? '' : 'open'}>
<summary class="dr-collapsible__head">
<h3>All sources (${sources.length})</h3>
<small>Click a card to see the full chunk · external link opens the original article</small>
</div>
</summary>
<div class="dr-source-list">
${sources.map((s) => renderSourceCard(s)).join('')}
</div>
</div>`;
</details>`;
// 7. Uncertainty
const uncertHtml = (data.what_remains_uncertain || []).length ? `
@@ -599,10 +670,21 @@
</div>` : '';
els.results.innerHTML = `
<div class="adv-result-actions">
<button id="advNewQuery" class="adv-new-query-btn" type="button">New query ↑</button>
<button class="adv-new-query-btn" type="button" onclick="window.print()">Print</button>
</div>
${bannerHtml}
${strengthsHtml}
<div class="dr-result-block">
<h3 style="margin:0 0 10px;font-size:1rem">Advocate brief</h3>
<div class="dr-section-head">
<h3 style="margin:0;font-size:1rem">Advocate brief</h3>
<div class="adv-brief-actions">
<button class="dr-copy-btn" id="advCopyBrief" type="button">Copy brief</button>
<button class="dr-copy-btn" id="advCopyArgs" type="button">Copy arguments</button>
<a class="dr-copy-btn" id="advDownloadMd" style="cursor:pointer">&#8595; .md</a>
</div>
</div>
<div class="dr-brief">${briefHtml}</div>
</div>
${weaknessesHtml}
@@ -612,6 +694,40 @@
${nextHtml}
`;
// Bind new-query button
const nq = document.getElementById('advNewQuery');
if (nq) nq.addEventListener('click', () => {
els.input.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => els.input.focus(), 300);
});
// Bind copy-brief
const copyBtn = document.getElementById('advCopyBrief');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
copyToClipboard(data.brief_markdown || '', copyBtn, 'Copy brief');
});
}
// Bind copy-arguments
const copyArgsBtn = document.getElementById('advCopyArgs');
if (copyArgsBtn) {
copyArgsBtn.addEventListener('click', () => {
const text = (data.client_strengths || []).map((s) => `- ${s}`).join('\n');
copyToClipboard(text, copyArgsBtn, 'Copy arguments');
});
}
// Bind download .md
const dlLink = document.getElementById('advDownloadMd');
if (dlLink) {
const md = buildExportMarkdown(data);
const blob = new Blob([md], { type: 'text/markdown; charset=utf-8' });
dlLink.href = URL.createObjectURL(blob);
dlLink.setAttribute('download', `advocate-brief-${Date.now()}.md`);
}
// Bind flip-brief button
els.results.querySelector('.adv-flip-btn')?.addEventListener('click', (e) => {
const toRole = e.currentTarget.dataset.flipRole;
if (toRole) flipBrief(toRole);
});
// Bind source-card clicks
els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => {
node.addEventListener('click', (e) => {
@@ -782,6 +898,232 @@
return Array.from(new Set(out));
}
function updateCharCount() {
if (!els.inputCount || !els.input) return;
const len = els.input.value.length;
els.inputCount.textContent = `${len.toLocaleString()} / 4,000`;
els.inputCount.classList.toggle('is-warn', len > 3500 && len <= 3900);
els.inputCount.classList.toggle('is-crit', len > 3900);
}
function saveToCache(result, formState) {
try { localStorage.setItem(CACHE_KEY, JSON.stringify({ result, formState, ts: Date.now() })); }
catch (e) { /* quota — silently ignore */ }
}
function loadFromCache() {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj || !obj.result || !obj.ts) return null;
if (Date.now() - obj.ts > 86400000) { localStorage.removeItem(CACHE_KEY); return null; }
return obj;
} catch (e) { return null; }
}
function showRestoreBanner({ result, formState, ts }) {
const age = Math.round((Date.now() - ts) / 60000);
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
const banner = document.createElement('div');
banner.id = 'advRestoreBanner';
banner.className = 'adv-restore-banner';
banner.innerHTML = `
<span class="adv-restore-banner__text">
Restore last session <em>(${ageStr})</em> — ${escapeHtml(formState.role || '?')} ·
&ldquo;${escapeHtml((formState.query || '').slice(0, 60))}${(formState.query || '').length > 60 ? '…' : ''}&rdquo;
</span>
<div class="adv-restore-banner__actions">
<button type="button" id="advRestoreYes">Restore</button>
<button type="button" id="advRestoreNo">Dismiss</button>
</div>`;
els.results.parentNode.insertBefore(banner, els.results);
document.getElementById('advRestoreYes').addEventListener('click', () => {
banner.remove();
restoreSession(result, formState);
});
document.getElementById('advRestoreNo').addEventListener('click', () => {
banner.remove();
localStorage.removeItem(CACHE_KEY);
});
}
function restoreSession(result, formState) {
els.input.value = formState.query || '';
updateCharCount();
if (formState.role) els.roleSelect.value = formState.role;
const radio = els.engineRadios.find((r) => r.value === formState.engine);
if (radio) radio.checked = true;
if (formState.slices) {
els.slices.forEach((btn) => {
const on = !!formState.slices[btn.dataset.slice];
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.querySelector('.dr-slice__badge').textContent = on ? 'on' : 'off';
});
}
lastResult = result;
result.query = formState.query || '';
renderResults(result, true);
const meta = result.trace_metadata || {};
setStatus(`Restored · confidence ${meta.citation_confidence || '?'}`, 'ok');
}
function showSliceHint(echrBtn, ncBtn, echrOff, ncOff) {
document.getElementById('advSliceHint')?.remove();
const names = [echrOff && 'ECHR', ncOff && 'Norwegian Courts'].filter(Boolean).join(' & ');
const hint = document.createElement('p');
hint.id = 'advSliceHint';
hint.className = 'adv-slice-hint';
hint.innerHTML = `${escapeHtml(names)} ${names.includes('&') ? 'are' : 'is'} often valuable for parent/family cases.
<button type="button" id="advSliceHintEnable">Enable ${escapeHtml(names)}</button>
<button type="button" id="advSliceHintDismiss">Dismiss</button>`;
document.querySelector('.dr-slice-section')?.after(hint);
document.getElementById('advSliceHintEnable').addEventListener('click', () => {
if (echrOff) activateSlice(echrBtn);
if (ncOff) activateSlice(ncBtn);
hint.remove();
});
document.getElementById('advSliceHintDismiss').addEventListener('click', () => hint.remove());
}
function activateSlice(btn) {
if (!btn) return;
btn.classList.add('is-on');
btn.setAttribute('aria-pressed', 'true');
btn.querySelector('.dr-slice__badge').textContent = 'on';
}
function copyToClipboard(text, btn, originalLabel) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
});
}
function buildExportMarkdown(data) {
const role = data.advocate_role || 'Unknown party';
const date = new Date().toISOString().slice(0, 10);
const strengths = (data.client_strengths || []).map((s) => `- ${s}`).join('\n');
const weaknesses = (data.opposing_weaknesses || []).map((w) => `- ${w}`).join('\n');
const uncertainty = (data.what_remains_uncertain || []).map((u) => `- ${u}`).join('\n');
return [
`# Advocate Brief — ${role}`,
`**Generated:** ${date}`,
'',
strengths ? '## Strongest Arguments\n' + strengths : '',
'',
'## Brief',
data.brief_markdown || '',
weaknesses ? '\n## Gaps in Opposing Position\n' + weaknesses : '',
uncertainty ? '\n## What Remains Uncertain\n' + uncertainty : '',
data.next_practical_step ? `\n## Next Practical Step\n${data.next_practical_step}` : '',
'',
'---',
'_Generated by Do Better Norge — Case Advocate_',
].filter((s) => s !== null && s !== '').join('\n');
}
function flipBrief(toRole) {
const optionExists = Array.from(els.roleSelect.options).some((o) => o.value === toRole);
if (optionExists) {
els.roleSelect.value = toRole;
if (els.roleCustom) els.roleCustom.classList.add('is-hidden');
} else {
els.roleSelect.value = '__other__';
if (els.roleCustom) {
els.roleCustom.classList.remove('is-hidden');
els.roleCustom.value = toRole;
}
}
els.form.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => els.form.requestSubmit(), 200);
}
function bindPreviewAngles() {
if (!els.previewAngles) return;
els.previewAngles.addEventListener('click', async () => {
const advocateRole = getAdvocateRole();
if (!advocateRole) { setStatus('Select who you are representing first.', 'error'); return; }
const query = (els.input.value || '').trim();
if (!query) { setStatus('Describe the case situation first.', 'error'); return; }
els.previewAngles.disabled = true;
els.previewAngles.textContent = 'Generating angles…';
setStatus('Generating research angles (steps 12 only, ~10s)…', 'busy');
try {
const res = await fetch('api/generate-subq.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
language: lang,
engine: getEngine(),
controls: getControls(),
advocate_role: advocateRole,
}),
credentials: 'same-origin',
});
const json = await res.json();
if (!res.ok || !json.ok) {
setStatus(json.message || `Error ${res.status}`, 'error');
return;
}
const sqs = json.sub_questions || [];
if (els.subQPreviewList) {
els.subQPreviewList.innerHTML = sqs.map((sq, i) => `
<div class="adv-subq-preview-item">
<label class="adv-subq-preview-label">Angle ${i + 1}</label>
<textarea class="adv-subq-edit" rows="3" data-sq-id="${escapeHtml(sq.id || ('q' + (i + 1)))}">${escapeHtml(sq.question || '')}</textarea>
${sq.rationale ? `<p class="adv-subq-rationale">${escapeHtml(sq.rationale)}</p>` : ''}
</div>`).join('');
}
if (els.subQPreview) {
els.subQPreview.classList.remove('is-hidden');
els.subQPreview.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
setStatus(`${sqs.length} research angles generated — review and edit, then run.`, 'ok');
} catch (err) {
setStatus(`Error generating angles: ${err.message || err}`, 'error');
} finally {
els.previewAngles.disabled = false;
els.previewAngles.textContent = 'Preview research angles first';
}
});
els.runWithAngles?.addEventListener('click', () => {
const textareas = els.subQPreviewList?.querySelectorAll('.adv-subq-edit') || [];
customSubQuestions = Array.from(textareas).map((ta, i) => ({
id: ta.dataset.sqId || `q${i + 1}`,
question: ta.value.trim(),
rationale: 'User-edited angle.',
})).filter((sq) => sq.question);
if (!customSubQuestions.length) {
setStatus('All angles are empty — edit at least one before running.', 'error');
customSubQuestions = null;
return;
}
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
els.form.requestSubmit();
});
els.discardAngles?.addEventListener('click', () => {
customSubQuestions = null;
if (els.subQPreview) els.subQPreview.classList.add('is-hidden');
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')