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>
1168 lines
32 KiB
HTML
1168 lines
32 KiB
HTML
<!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 20–30 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>
|