james-dashboard/index.html

607 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>James Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #F8F7F6;
--bg-card: #FFFFFF;
--border: #E5E2DE;
--text: #1C1917;
--text-muted: #78716C;
--accent: #B45309;
--accent-light: #FEF3C7;
--success: #059669;
--success-light: #ECFDF5;
--danger: #DC2626;
--danger-light: #FEF2F2;
--info: #6366f1;
--info-light: #EEF2FF;
--james: #B45309;
--johan: #2563EB;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
.container { max-width: 1400px; margin: 0 auto; }
.three-column {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
}
@media (max-width: 1100px) {
.three-column { grid-template-columns: 1fr 1fr; }
.column-done { grid-column: span 2; }
}
@media (max-width: 700px) {
.three-column { grid-template-columns: 1fr; }
.column-done { grid-column: span 1; }
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
h1 { font-size: 1.75rem; font-weight: 700; }
h1 .accent { color: var(--accent); }
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.updated { font-size: 0.8rem; color: var(--text-muted); }
/* Agents Bar */
.agents-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 12px 16px;
background: var(--bg-card);
border-radius: 10px;
border: 1px solid var(--border);
}
.agents-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.agents-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg);
border-radius: 8px;
text-decoration: none;
color: var(--text);
font-size: 0.85rem;
font-weight: 500;
transition: background 0.15s, transform 0.1s;
border: 1px solid var(--border);
}
.agent-link:hover {
background: var(--accent-light);
transform: translateY(-1px);
}
.agent-link.default {
background: var(--accent-light);
border-color: var(--accent);
}
.agent-emoji { font-size: 1rem; }
.section { margin-bottom: 20px; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.section-indicator {
width: 4px;
height: 20px;
border-radius: 2px;
}
.section-indicator.alerts { background: var(--danger); }
.section-indicator.james { background: var(--james); }
.section-indicator.johan { background: var(--johan); }
.section-indicator.progress { background: var(--info); }
.section-indicator.done { background: var(--success); }
.section-title {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.section-count {
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg);
padding: 2px 8px;
border-radius: 10px;
}
.card {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
}
.card-collapsed .card-content { display: none; }
.card-collapsed .collapse-toggle::after { content: '▶'; }
.collapse-toggle {
cursor: pointer;
user-select: none;
font-size: 0.65rem;
color: var(--text-muted);
}
.collapse-toggle::after { content: '▼'; margin-left: 4px; }
/* Alerts/News */
.alert-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.alert-item:last-child { border-bottom: none; }
.alert-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.alert-title {
font-weight: 500;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 8px;
}
.alert-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.alert-dot.error { background: var(--danger); }
.alert-dot.warning { background: var(--accent); }
.alert-dot.success { background: var(--success); }
.alert-dot.info { background: var(--info); }
.alert-time {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.alert-body {
font-size: 0.8rem;
color: var(--text-muted);
margin-left: 16px;
}
.alert-source {
font-size: 0.7rem;
color: var(--text-muted);
margin-left: 16px;
margin-top: 4px;
font-style: italic;
}
/* Tasks */
.task-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.task-item:last-child { border-bottom: none; }
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.task-title {
font-weight: 500;
font-size: 0.85rem;
flex: 1;
}
.task-domain {
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg);
padding: 2px 6px;
border-radius: 4px;
}
.task-notes {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 6px;
line-height: 1.4;
}
.task-meta {
display: flex;
gap: 6px;
margin-top: 8px;
}
.badge {
font-size: 0.65rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-high { background: var(--danger-light); color: var(--danger); }
.badge-medium { background: var(--accent-light); color: var(--accent); }
.badge-low { background: var(--bg); color: var(--text-muted); }
.badge-done { background: var(--success-light); color: var(--success); }
.badge-in-progress { background: var(--info-light); color: var(--info); }
.badge-pending { background: var(--bg); color: var(--text-muted); }
.empty {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
/* Latest Briefing Banner */
.briefing-banner {
background: linear-gradient(135deg, #8B5CF6 0%, #6366f1 100%);
color: white;
padding: 16px 20px;
border-radius: 12px;
margin-bottom: 24px;
}
.briefing-banner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.briefing-banner-title {
font-weight: 600;
font-size: 0.9rem;
}
.briefing-banner-time {
font-size: 0.75rem;
opacity: 0.8;
}
.briefing-banner-summary {
font-size: 0.8rem;
opacity: 0.9;
line-height: 1.5;
}
@media (max-width: 600px) {
body { padding: 16px; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1><span class="accent">James</span> Dashboard</h1>
<div class="header-right">
<span class="updated" id="last-updated">Loading...</span>
</div>
</header>
<!-- Latest Briefing Banner -->
<div class="briefing-banner" id="briefing-banner">
<div class="briefing-banner-header">
<span class="briefing-banner-title">Loading briefing...</span>
</div>
</div>
<!-- Agents Quick Links -->
<div class="agents-bar" id="agents-bar">
<span class="agents-label">Agents:</span>
<div class="agents-links" id="agents-links">
<span class="loading">Loading...</span>
</div>
</div>
<div class="three-column">
<!-- Column 1: Alerts + James Tasks -->
<div class="column">
<div class="section">
<div class="section-header">
<div class="section-header-left">
<div class="section-indicator alerts"></div>
<span class="section-title">Alerts</span>
</div>
<span class="section-count" id="alerts-count">0</span>
</div>
<div class="card" id="alerts-container">
<div class="empty">No alerts</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-header-left">
<div class="section-indicator james"></div>
<span class="section-title">James — Pending</span>
</div>
<span class="section-count" id="james-pending-count">0</span>
</div>
<div class="card" id="james-pending-container">
<div class="empty">No pending tasks</div>
</div>
</div>
</div>
<!-- Column 2: Johan Tasks + In Progress -->
<div class="column">
<div class="section">
<div class="section-header">
<div class="section-header-left">
<div class="section-indicator johan"></div>
<span class="section-title">Johan — Pending</span>
</div>
<span class="section-count" id="johan-pending-count">0</span>
</div>
<div class="card" id="johan-pending-container">
<div class="empty">No pending tasks</div>
</div>
</div>
<div class="section">
<div class="section-header">
<div class="section-header-left">
<div class="section-indicator progress"></div>
<span class="section-title">In Progress</span>
</div>
<span class="section-count" id="progress-count">0</span>
</div>
<div class="card" id="progress-container">
<div class="empty">Nothing in progress</div>
</div>
</div>
</div>
<!-- Column 3: Done -->
<div class="column column-done">
<div class="section">
<div class="section-header">
<div class="section-header-left">
<div class="section-indicator done"></div>
<span class="section-title">Done</span>
<span class="collapse-toggle" onclick="toggleDone()"></span>
</div>
<span class="section-count" id="done-count">0</span>
</div>
<div class="card" id="done-container">
<div class="empty">No completed tasks</div>
</div>
</div>
</div>
</div>
</div>
<script>
let doneCollapsed = false;
function toggleDone() {
doneCollapsed = !doneCollapsed;
document.getElementById('done-container').parentElement.classList.toggle('card-collapsed', doneCollapsed);
}
function formatTime(iso) {
const d = new Date(iso);
const now = new Date();
const diff = now - d;
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function priorityBadge(priority) {
return `<span class="badge badge-${priority}">${priority}</span>`;
}
function ownerBadge(owner) {
const color = owner === 'james' ? 'var(--james)' : 'var(--johan)';
return `<span class="badge" style="background: ${color}20; color: ${color}">${owner}</span>`;
}
function renderTask(t, showOwner = false) {
return `
<div class="task-item">
<div class="task-header">
<div class="task-title">${t.title}</div>
${t.domain ? `<span class="task-domain">${t.domain}</span>` : ''}
</div>
${t.notes ? `<div class="task-notes">${t.notes}</div>` : ''}
<div class="task-meta">
${priorityBadge(t.priority)}
${showOwner ? ownerBadge(t.owner) : ''}
</div>
</div>
`;
}
function renderAlert(n) {
return `
<div class="alert-item">
<div class="alert-header">
<div class="alert-title">
<span class="alert-dot ${n.type}"></span>
${n.title}
</div>
<span class="alert-time">${formatTime(n.timestamp)}</span>
</div>
<div class="alert-body">${n.body}</div>
${n.source ? `<div class="alert-source">— ${n.source}</div>` : ''}
</div>
`;
}
async function loadData() {
try {
const [tasksRes, newsRes, briefingsRes] = await Promise.all([
fetch('/api/tasks'),
fetch('/api/news'),
fetch('/api/briefings')
]);
const tasksData = await tasksRes.json();
const newsData = await newsRes.json();
const briefingsData = await briefingsRes.json();
const tasks = tasksData.tasks || [];
const news = newsData.items || [];
const briefings = briefingsData.briefings || [];
// Filter tasks by owner and status
const jamesPending = tasks.filter(t => t.owner === 'james' && t.status === 'pending');
const johanPending = tasks.filter(t => t.owner === 'johan' && t.status === 'pending');
const inProgress = tasks.filter(t => t.status === 'in-progress');
const done = tasks.filter(t => t.status === 'done').slice(0, 10); // Last 10
// Filter news to last 24h only
const oneDayAgo = Date.now() - 86400000;
const recentNews = news.filter(n => new Date(n.timestamp).getTime() > oneDayAgo);
// Render alerts
document.getElementById('alerts-count').textContent = recentNews.length;
document.getElementById('alerts-container').innerHTML = recentNews.length
? recentNews.map(renderAlert).join('')
: '<div class="empty">No alerts in last 24h</div>';
// Render task groups
document.getElementById('james-pending-count').textContent = jamesPending.length;
document.getElementById('james-pending-container').innerHTML = jamesPending.length
? jamesPending.map(t => renderTask(t)).join('')
: '<div class="empty">No pending tasks</div>';
document.getElementById('johan-pending-count').textContent = johanPending.length;
document.getElementById('johan-pending-container').innerHTML = johanPending.length
? johanPending.map(t => renderTask(t)).join('')
: '<div class="empty">No pending tasks</div>';
document.getElementById('progress-count').textContent = inProgress.length;
document.getElementById('progress-container').innerHTML = inProgress.length
? inProgress.map(t => renderTask(t, true)).join('')
: '<div class="empty">Nothing in progress</div>';
document.getElementById('done-count').textContent = done.length;
document.getElementById('done-container').innerHTML = done.length
? done.map(t => renderTask(t, true)).join('')
: '<div class="empty">No completed tasks</div>';
// Render latest briefing banner
if (briefings.length > 0) {
const latest = briefings[0];
document.getElementById('briefing-banner').innerHTML = `
<div class="briefing-banner-header">
<span class="briefing-banner-title">📋 ${latest.title}</span>
<span class="briefing-banner-time">${formatTime(latest.created_at)}</span>
</div>
<div class="briefing-banner-summary">${latest.summary || latest.weather + ' • ' + (latest.markets || '').substring(0, 80) + '...'}</div>
`;
}
document.getElementById('last-updated').textContent = 'Updated ' + formatTime(new Date().toISOString());
} catch (e) {
console.error('Failed to load data:', e);
}
}
async function loadAgents() {
try {
const res = await fetch('/api/agents');
const data = await res.json();
const agents = data.agents || [];
if (agents.length === 0) {
document.getElementById('agents-links').innerHTML = '<span class="loading">No agents configured</span>';
return;
}
document.getElementById('agents-links').innerHTML = agents.map(a => `
<a href="${a.url}" target="_blank" class="agent-link ${a.default ? 'default' : ''}">
<span class="agent-emoji">${a.emoji}</span>
<span>${a.name}</span>
</a>
`).join('');
} catch (e) {
console.error('Failed to load agents:', e);
document.getElementById('agents-links').innerHTML = '<span class="loading">Failed to load</span>';
}
}
loadAgents();
loadData();
setInterval(loadData, 30000);
</script>
</body>
</html>