programme-pulse-chat/templates/index.html
DJP b70d148b94 Productionise Programme Pulse
Backend
- Routes moved under /api/, JWT bearer auth via @before_request
- DEV_AUTH_BYPASS escape hatch for local dev
- In-memory chat history and report state replaced with Postgres tables
  (preferences, chat_messages, reports, feedback_events) keyed on user
- SQLAlchemy 2.x + Alembic migrations run on container start
- Graceful Airtable failure handling — bad creds no longer 500 the API
- Per-user data isolation via g.user_email from validated token

Frontend
- React + Vite + TypeScript SPA at /programme-pulse/
- MSAL.js (PKCE, sessionStorage, ID token to backend)
- VITE_DEV_AUTH_BYPASS mirrors backend bypass for local dev
- Streaming chat via fetch ReadableStream + SSE parsing
- Charts via chart.js, markdown via react-markdown + remark-gfm
- Full UI parity with the original templates/index.html

Deploy (optical-dev split-build pattern)
- Dockerfile + docker-compose.yml (name: programme-pulse pinned;
  app + Postgres; 127.0.0.1 binding only)
- deploy/apache-programme-pulse.conf.tmpl with flushpackets=on for SSE
- deploy/deploy.sh mirrors OSOP — port auto-pick (5051..5099),
  apache conf render, frontend build in throwaway node container,
  rsync to /var/www/html/programme-pulse, /api/health poll

Tests
- 49 passing; new tests for DB-backed preferences and JWT auth helpers
- SQLite-backed test fixture in tests/conftest.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:08:28 -04:00

1168 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Programme Pulse</title>
<script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--surface: #ffffff;
--surface-secondary: #f9f9fb;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.14);
--text-primary: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #aeaeb2;
--accent: #0071e3;
--accent-hover: #0077ed;
--accent-soft: #e8f0fd;
--green: #28a745;
--green-soft: #edfaf1;
--green-border: #c3e6cb;
--red: #d70015;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04);
--radius: 14px;
--radius-sm: 10px;
--radius-xs: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
/* Header */
header {
background: rgba(245,245,247,0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 10px; }
.header-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
}
header h1 {
font-size: 0.9rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
}
header .subtitle {
font-size: 0.78rem;
color: var(--text-tertiary);
font-weight: 400;
}
.header-sep { width: 1px; height: 14px; background: var(--border-strong); }
.header-btn {
background: none;
border: 1px solid var(--border-strong);
color: var(--text-secondary);
padding: 5px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 5px;
}
.header-btn:hover {
background: var(--surface);
color: var(--text-primary);
border-color: var(--text-tertiary);
}
.header-right { display: flex; align-items: center; gap: 8px; }
/* Main content */
main {
flex: 1;
max-width: 760px;
width: 100%;
margin: 0 auto;
padding: 20px 20px 140px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Messages */
#messages {
display: flex;
flex-direction: column;
gap: 8px;
}
.msg {
max-width: 82%;
padding: 11px 15px;
border-radius: var(--radius);
line-height: 1.5;
font-size: 0.875rem;
word-break: break-word;
}
.msg.user {
align-self: flex-end;
background: var(--accent);
color: #fff;
border-bottom-right-radius: 4px;
font-weight: 400;
white-space: pre-wrap;
}
.msg.assistant {
align-self: flex-start;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
max-width: 100%;
}
/* Streaming indicator — subtle blue top border that fades when done */
.msg.assistant.streaming {
border-top: 2px solid var(--accent);
}
.msg.assistant.streaming::after {
content: '';
display: block;
height: 2px;
width: 32px;
background: var(--accent);
border-radius: 2px;
margin-top: 10px;
animation: pulse-bar 1.2s ease-in-out infinite;
}
@keyframes pulse-bar {
0%, 100% { opacity: 0.3; transform: scaleX(0.6); }
50% { opacity: 1; transform: scaleX(1); }
}
/* Markdown rendering inside assistant bubbles */
.msg.assistant h1,
.msg.assistant h2 {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
margin: 14px 0 6px;
}
.msg.assistant h1:first-child,
.msg.assistant h2:first-child { margin-top: 0; }
.msg.assistant h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin: 12px 0 4px;
}
.msg.assistant h3:first-child { margin-top: 0; }
.msg.assistant p { margin: 0 0 8px; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant ul,
.msg.assistant ol {
padding-left: 18px;
margin: 4px 0 8px;
}
.msg.assistant li { margin: 3px 0; }
.msg.assistant strong { font-weight: 600; }
.msg.assistant em { font-style: italic; }
.msg.assistant hr {
border: none;
border-top: 1px solid var(--border);
margin: 12px 0;
}
.msg.assistant code {
background: var(--surface-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
font-size: 0.82rem;
font-family: "SF Mono", monospace;
}
.msg-wrap { max-width: 82%; }
.msg.system-msg {
align-self: center;
background: none;
color: var(--text-tertiary);
font-size: 0.75rem;
padding: 4px 0;
border-radius: 0;
max-width: 100%;
text-align: center;
}
/* Feedback buttons */
.msg-wrap {
display: flex;
flex-direction: column;
align-self: flex-start;
}
.feedback-row {
display: flex;
gap: 4px;
margin-top: 4px;
padding-left: 2px;
}
.thumb-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 5px;
border-radius: 6px;
font-size: 0.75rem;
line-height: 1;
color: var(--text-tertiary);
transition: all 0.12s ease;
}
.thumb-btn:hover { background: var(--surface-secondary); color: var(--text-secondary); }
.thumb-btn.active-up { color: var(--green); }
.thumb-btn.active-down { color: var(--red); }
.thumb-btn:disabled { opacity: 0.3; cursor: default; }
/* Typing indicator */
.typing {
align-self: flex-start;
display: flex;
align-items: center;
gap: 4px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
}
.typing span {
width: 6px; height: 6px;
background: var(--text-tertiary);
border-radius: 50%;
animation: bounce 1.2s infinite ease-in-out;
}
.typing span:nth-child(2) { animation-delay: 0.2s; }
.typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
40% { transform: translateY(-5px); opacity: 1; }
}
/* Reports card */
.reports-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.reports-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.reports-card-header .icon {
width: 28px; height: 28px;
background: var(--accent-soft);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem;
}
.reports-card-header h2 {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.reports-card-header .subtitle {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 1px;
}
.reports-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* History section */
.history-toggle {
background: none;
border: none;
color: var(--text-tertiary);
font-size: 0.75rem;
cursor: pointer;
padding: 8px 0 0;
display: flex;
align-items: center;
gap: 4px;
font-family: inherit;
transition: color 0.12s;
}
.history-toggle:hover { color: var(--text-secondary); }
.history-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
font-size: 0.78rem;
}
.history-item .history-label {
color: var(--text-secondary);
font-weight: 500;
}
.history-item .history-links {
display: flex;
gap: 8px;
}
.history-item a {
color: var(--accent);
text-decoration: none;
font-size: 0.75rem;
}
.history-item a:hover { text-decoration: underline; }
/* Buttons */
.btn {
padding: 8px 16px;
border-radius: 20px;
border: none;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
font-family: inherit;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.btn:disabled { opacity: 0.38; cursor: default; }
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-ghost {
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--border-strong);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface);
border-color: var(--text-tertiary);
}
.btn-green {
background: var(--green-soft);
color: var(--green);
border: 1px solid var(--green-border);
}
.btn-green:hover:not(:disabled) { background: #d4f5de; }
.btn-stop {
background: #fff0f2;
color: var(--red);
border: 1px solid rgba(215,0,21,0.2);
}
.btn-stop:hover { background: #ffe0e4; }
#copy-status {
font-size: 0.75rem;
color: var(--green);
margin-top: 10px;
min-height: 16px;
display: flex;
align-items: center;
gap: 4px;
}
/* Input bar */
.input-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: rgba(245,245,247,0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
padding: 12px 20px 16px;
}
.input-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
#chat-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 12px;
font-size: 0.875rem;
font-family: inherit;
color: var(--text-primary);
background: var(--surface);
resize: none;
height: 40px;
line-height: 1.4;
transition: border-color 0.15s;
outline: none;
}
#chat-input:focus { border-color: var(--accent); }
#chat-input::placeholder { color: var(--text-tertiary); }
.input-actions {
display: flex;
gap: 6px;
}
#generate-status {
font-size: 0.75rem;
color: var(--text-tertiary);
min-height: 16px;
padding: 0 2px;
}
#generate-status.ready { color: var(--green); }
/* Preferences Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-header p {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 2px;
}
.modal-close {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-tertiary);
font-size: 1.1rem;
line-height: 1;
border-radius: 6px;
transition: all 0.12s;
}
.modal-close:hover { background: var(--surface-secondary); color: var(--text-primary); }
.modal-body {
overflow-y: auto;
padding: 16px 20px;
flex: 1;
}
.pref-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-xs);
margin-bottom: 4px;
border: 1px solid var(--border);
background: var(--surface-secondary);
font-size: 0.835rem;
color: var(--text-primary);
line-height: 1.45;
}
.pref-item span { flex: 1; }
.pref-delete {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
font-size: 0.75rem;
padding: 2px 4px;
border-radius: 4px;
flex-shrink: 0;
margin-top: 1px;
transition: all 0.12s;
}
.pref-delete:hover { background: #fef0f0; color: var(--red); }
.pref-empty {
text-align: center;
color: var(--text-tertiary);
font-size: 0.835rem;
padding: 24px 0;
}
/* Tables */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
margin: 8px 0 12px;
}
.table-wrap:last-child { margin-bottom: 0; }
.msg.assistant table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.msg.assistant th {
text-align: left;
font-weight: 600;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 7px 12px;
background: var(--surface-secondary);
border-bottom: 1px solid var(--border-strong);
white-space: nowrap;
letter-spacing: 0.01em;
}
.msg.assistant td {
padding: 7px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
line-height: 1.45;
}
.msg.assistant tbody tr:last-child td { border-bottom: none; }
.msg.assistant tbody tr:hover td { background: rgba(0,0,0,0.016); }
/* Charts */
.chart-wrap {
margin: 10px 0 14px;
padding: 14px 16px 10px;
background: var(--surface-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.chart-wrap:last-child { margin-bottom: 0; }
.chart-title {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.chart-wrap canvas { max-height: 340px; }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="header-dot"></div>
<h1>Programme Pulse</h1>
<div class="header-sep"></div>
<span class="subtitle">L'Oréal OLIVER</span>
</div>
<div class="header-right">
<button class="header-btn" onclick="openPreferences()">Preferences</button>
<button class="header-btn" onclick="refreshData()">
<span></span> Refresh
</button>
</div>
</header>
<main>
<div id="messages">
<div class="msg system-msg">Ask me anything about the programme.</div>
</div>
<div class="reports-card" id="reports-card" style="display:none;">
<div class="reports-card-header">
<div class="icon">📋</div>
<div>
<h2>Reports ready</h2>
<div class="subtitle">Manager Summary and Full Report</div>
</div>
</div>
<div class="reports-row">
<button class="btn btn-ghost" onclick="downloadSummary()">↓ Manager Summary</button>
<button class="btn btn-green" onclick="copyForTeams()">⎘ Copy for Teams</button>
<button class="btn btn-ghost" onclick="downloadFull()">↓ Full Report</button>
</div>
<div id="copy-status"></div>
<button class="history-toggle" onclick="toggleHistory()" id="history-toggle" style="display:none;">
▸ Past reports
</button>
<div class="history-list" id="history-list" style="display:none;"></div>
</div>
</main>
<div class="input-bar">
<div class="input-inner">
<div class="input-row">
<textarea
id="chat-input"
placeholder="Ask about the programme…"
rows="1"
></textarea>
<div class="input-actions">
<button class="btn btn-primary" onclick="sendMessage()" id="send-btn">Send</button>
<button class="btn btn-ghost" onclick="generateReports()" id="generate-btn">Generate reports</button>
</div>
</div>
<div id="generate-status"></div>
</div>
</div>
<!-- Preferences Modal -->
<div class="modal-overlay" id="prefs-modal" style="display:none;" onclick="closePreferencesOnOverlay(event)">
<div class="modal">
<div class="modal-header">
<div>
<h2>Preferences</h2>
<p>Saved from thumbs feedback. Applied to every chat session.</p>
</div>
<button class="modal-close" onclick="closePreferences()"></button>
</div>
<div class="modal-body" id="prefs-body">
<div class="pref-empty">Loading…</div>
</div>
</div>
</div>
<script>
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('chat-input');
let activeAbortController = null;
function renderContent(text) {
return marked.parse(text || '')
.replace(/<table>/g, '<div class="table-wrap"><table>')
.replace(/<\/table>/g, '</table></div>');
}
function renderCharts(container) {
container.querySelectorAll('code.language-chart').forEach(codeEl => {
try {
const spec = JSON.parse(codeEl.textContent);
const isHbar = spec.type === 'hbar';
const chartType = (spec.type === 'line') ? 'line' : 'bar';
const wrap = document.createElement('div');
wrap.className = 'chart-wrap';
if (spec.title) {
const titleEl = document.createElement('div');
titleEl.className = 'chart-title';
titleEl.textContent = spec.title;
wrap.appendChild(titleEl);
}
const canvas = document.createElement('canvas');
wrap.appendChild(canvas);
new Chart(canvas, {
type: chartType,
data: {
labels: spec.labels || [],
datasets: (spec.series || []).map(s => ({
label: s.label || '',
data: s.data || [],
backgroundColor: s.color || '#0071e3',
borderColor: s.color || '#0071e3',
borderWidth: chartType === 'line' ? 2 : 0,
borderRadius: chartType === 'bar' ? 3 : 0,
fill: false,
}))
},
options: {
indexAxis: isHbar ? 'y' : 'x',
responsive: true,
plugins: {
legend: { position: 'top', labels: { font: { size: 11 }, padding: 10, boxWidth: 10 } },
},
scales: {
x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
y: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } },
}
}
});
codeEl.closest('pre').replaceWith(wrap);
} catch (_) {
// Incomplete or invalid JSON — leave as code block
}
});
}
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
function setSendState(streaming) {
const btn = document.getElementById('send-btn');
if (streaming) {
btn.textContent = '◼ Stop';
btn.className = 'btn btn-stop';
btn.onclick = stopStream;
} else {
btn.textContent = 'Send';
btn.className = 'btn btn-primary';
btn.onclick = sendMessage;
btn.disabled = false;
}
}
function stopStream() {
if (activeAbortController) activeAbortController.abort();
}
inputEl.addEventListener('input', () => {
inputEl.style.height = '40px';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
});
function addMessage(role, text) {
if (role === 'assistant') {
const wrap = document.createElement('div');
wrap.className = 'msg-wrap';
const div = document.createElement('div');
div.className = 'msg assistant';
div.innerHTML = renderContent(text);
renderCharts(div);
wrap.appendChild(div);
const row = document.createElement('div');
row.className = 'feedback-row';
const upBtn = document.createElement('button');
upBtn.className = 'thumb-btn';
upBtn.textContent = '👍';
upBtn.title = 'Good response';
const downBtn = document.createElement('button');
downBtn.className = 'thumb-btn';
downBtn.textContent = '👎';
downBtn.title = 'Not helpful';
async function sendFeedback(rating) {
upBtn.disabled = true;
downBtn.disabled = true;
upBtn.classList.toggle('active-up', rating === 'up');
downBtn.classList.toggle('active-down', rating === 'down');
await fetch('/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, message: text }),
});
}
upBtn.onclick = () => sendFeedback('up');
downBtn.onclick = () => sendFeedback('down');
row.appendChild(upBtn);
row.appendChild(downBtn);
wrap.appendChild(row);
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: 'smooth', block: 'end' });
return div;
}
const div = document.createElement('div');
div.className = `msg ${role}`;
div.textContent = text;
messagesEl.appendChild(div);
div.scrollIntoView({ behavior: 'smooth', block: 'end' });
return div;
}
// Create a streaming assistant bubble — returns { div, wrap, finalize(fullText) }
function createStreamingBubble() {
const wrap = document.createElement('div');
wrap.className = 'msg-wrap';
const div = document.createElement('div');
div.className = 'msg assistant streaming';
wrap.appendChild(div);
messagesEl.appendChild(wrap);
div.scrollIntoView({ behavior: 'smooth', block: 'end' });
function finalize(fullText, aborted = false) {
div.classList.remove('streaming');
if (!fullText) {
div.innerHTML = '<p style="color:var(--text-tertiary);font-size:0.835rem;">Response cancelled.</p>';
return;
}
div.innerHTML = renderContent(fullText);
renderCharts(div);
if (aborted) {
const note = document.createElement('p');
note.style.cssText = 'font-size:0.72rem;color:var(--text-tertiary);margin-top:8px;';
note.textContent = '— stopped';
div.appendChild(note);
}
const row = document.createElement('div');
row.className = 'feedback-row';
const upBtn = document.createElement('button');
upBtn.className = 'thumb-btn';
upBtn.textContent = '👍';
upBtn.title = 'Good response';
const downBtn = document.createElement('button');
downBtn.className = 'thumb-btn';
downBtn.textContent = '👎';
downBtn.title = 'Not helpful';
async function sendFeedback(rating) {
upBtn.disabled = true;
downBtn.disabled = true;
upBtn.classList.toggle('active-up', rating === 'up');
downBtn.classList.toggle('active-down', rating === 'down');
await fetch('/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, message: fullText }),
});
}
upBtn.onclick = () => sendFeedback('up');
downBtn.onclick = () => sendFeedback('down');
row.appendChild(upBtn);
row.appendChild(downBtn);
wrap.appendChild(row);
div.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
return { div, finalize };
}
function addTyping() {
const div = document.createElement('div');
div.className = 'typing';
div.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(div);
div.scrollIntoView({ behavior: 'smooth', block: 'end' });
return div;
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
inputEl.style.height = '40px';
setSendState(true);
addMessage('user', text);
const { div: streamDiv, finalize } = createStreamingBubble();
let fullText = '';
let aborted = false;
activeAbortController = new AbortController();
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
signal: activeAbortController.signal,
});
if (!res.ok || !res.body) {
fullText = 'Error reaching the server.';
} else {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') break;
try {
const data = JSON.parse(payload);
if (data.error) {
fullText = data.error;
} else if (data.chunk) {
fullText += data.chunk;
streamDiv.innerHTML = renderContent(fullText);
if (window.innerHeight + window.scrollY >= document.body.scrollHeight - 200) {
window.scrollTo({ top: document.body.scrollHeight });
}
}
} catch {}
}
}
}
} catch (e) {
if (e.name === 'AbortError') {
aborted = true;
} else {
fullText = fullText || 'Error reaching the server.';
}
} finally {
activeAbortController = null;
finalize(fullText || (aborted ? '' : 'No response.'), aborted);
setSendState(false);
}
}
async function generateReports() {
const btn = document.getElementById('generate-btn');
const status = document.getElementById('generate-status');
btn.disabled = true;
status.className = '';
status.textContent = 'Generating — this takes about 2030 seconds…';
try {
const res = await fetch('/generate', { method: 'POST' });
const data = await res.json();
if (data.status === 'ok') {
status.textContent = '✓ Reports ready.';
status.className = 'ready';
const card = document.getElementById('reports-card');
card.style.display = 'block';
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
loadHistory();
} else {
status.textContent = 'Error: ' + (data.error || 'Unknown error');
}
} catch {
status.textContent = 'Error reaching the server.';
} finally {
btn.disabled = false;
}
}
async function copyForTeams() {
const status = document.getElementById('copy-status');
try {
const res = await fetch('/copy/summary');
const data = await res.json();
await navigator.clipboard.writeText(data.markdown);
status.textContent = '✓ Copied — paste into Teams or email.';
setTimeout(() => { status.textContent = ''; }, 4000);
} catch {
status.textContent = 'Copy failed — download the Word doc instead.';
}
}
function downloadSummary() { window.location.href = '/download/summary'; }
function downloadFull() { window.location.href = '/download/full'; }
async function refreshData() {
const res = await fetch('/refresh', { method: 'POST' });
const data = await res.json();
if (data.status === 'ok') {
addMessage('system-msg', `Data refreshed — ${data.tasks} tasks loaded.`);
}
}
// Report history
let historyData = [];
let historyVisible = false;
async function loadHistory() {
try {
const res = await fetch('/history');
const data = await res.json();
historyData = data.runs || [];
const toggle = document.getElementById('history-toggle');
// Show toggle if there are older reports beyond current session's (more than 1 run)
if (historyData.length > 1) {
toggle.style.display = 'flex';
}
} catch {}
}
function toggleHistory() {
historyVisible = !historyVisible;
const toggle = document.getElementById('history-toggle');
const list = document.getElementById('history-list');
if (historyVisible) {
toggle.textContent = '▾ Past reports';
list.style.display = 'flex';
renderHistory();
} else {
toggle.textContent = '▸ Past reports';
list.style.display = 'none';
}
}
function renderHistory() {
const list = document.getElementById('history-list');
list.innerHTML = '';
// Skip the first (most recent) — already shown in the reports card
const older = historyData.slice(1);
if (older.length === 0) {
list.innerHTML = '<div style="font-size:0.78rem;color:var(--text-tertiary);padding:4px 2px;">No older reports.</div>';
return;
}
for (const run of older) {
const item = document.createElement('div');
item.className = 'history-item';
const label = run.summary?.label || run.full?.label || run.ts;
item.innerHTML = `
<span class="history-label">${label}</span>
<span class="history-links">
${run.summary ? `<a href="/download/history/${run.summary.filename}">Summary</a>` : ''}
${run.full ? `<a href="/download/history/${run.full.filename}">Full Report</a>` : ''}
</span>
`;
list.appendChild(item);
}
}
// Load history on page load in case reports already exist
loadHistory().then(() => {
if (historyData.length >= 1) {
document.getElementById('reports-card').style.display = 'block';
}
});
// Preferences modal
async function openPreferences() {
document.getElementById('prefs-modal').style.display = 'flex';
await refreshPreferences();
}
function closePreferences() {
document.getElementById('prefs-modal').style.display = 'none';
}
function closePreferencesOnOverlay(e) {
if (e.target === document.getElementById('prefs-modal')) closePreferences();
}
async function refreshPreferences() {
const body = document.getElementById('prefs-body');
body.innerHTML = '<div class="pref-empty">Loading…</div>';
try {
const res = await fetch('/preferences');
const data = await res.json();
const prefs = data.preferences || [];
if (prefs.length === 0) {
body.innerHTML = '<div class="pref-empty">No preferences saved yet. Use 👍 and 👎 on responses to build them up.</div>';
return;
}
body.innerHTML = '';
prefs.forEach((pref, i) => {
const item = document.createElement('div');
item.className = 'pref-item';
item.innerHTML = `
<span>${pref}</span>
<button class="pref-delete" title="Delete" onclick="deletePreference(${i})">✕</button>
`;
body.appendChild(item);
});
} catch {
body.innerHTML = '<div class="pref-empty">Could not load preferences.</div>';
}
}
async function deletePreference(index) {
await fetch(`/preferences/${index}`, { method: 'DELETE' });
await refreshPreferences();
}
</script>
</body>
</html>