983 lines
31 KiB
HTML
983 lines
31 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); }
|
||
|
||
/* Claude Usage Mini Chart */
|
||
.claude-chart-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 10px;
|
||
background: var(--bg);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.claude-chart-label { font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; }
|
||
.claude-chart-pct { font-size: 0.85rem; font-weight: 600; }
|
||
.claude-chart-pct.ok { color: var(--success); }
|
||
.claude-chart-pct.warning { color: var(--accent); }
|
||
.claude-chart-pct.error { color: var(--danger); }
|
||
.claude-chart-svg { display: block; }
|
||
|
||
/* 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; }
|
||
|
||
/* Status (inline in header) */
|
||
.status-inline {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
padding-right: 12px;
|
||
border-right: 1px solid var(--border);
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.status-inline:empty {
|
||
display: none;
|
||
}
|
||
|
||
.status-inline .value.ok { color: var(--success); }
|
||
.status-inline .value.warning { color: var(--accent); }
|
||
.status-inline .value.error { color: var(--danger); }
|
||
|
||
.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.deliveries { background: #8B5CF6; }
|
||
.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;
|
||
}
|
||
|
||
/* Deliveries */
|
||
.delivery-item {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.delivery-item:last-child { border-bottom: none; }
|
||
|
||
.delivery-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.delivery-title {
|
||
font-weight: 500;
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.delivery-status {
|
||
font-size: 0.7rem;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
}
|
||
.delivery-status.out_for_delivery { background: #FEF3C7; color: #B45309; }
|
||
.delivery-status.in_transit { background: #EEF2FF; color: #6366f1; }
|
||
.delivery-status.shipped { background: var(--bg); color: var(--text-muted); }
|
||
.delivery-status.delayed { background: #FEF2F2; color: #DC2626; }
|
||
|
||
.delivery-meta {
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Tasks */
|
||
.task-item {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.task-item:last-child { border-bottom: none; }
|
||
.task-item:hover .task-actions { opacity: 1; }
|
||
|
||
.task-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.task-checkbox {
|
||
width: 22px;
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: var(--text-muted);
|
||
border-radius: 50%;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.task-checkbox:hover {
|
||
background: var(--accent);
|
||
color: white;
|
||
}
|
||
|
||
.task-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.task-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border: none;
|
||
background: var(--bg-secondary);
|
||
color: var(--text-muted);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
}
|
||
.task-btn:hover {
|
||
background: var(--accent);
|
||
color: white;
|
||
}
|
||
.task-btn.delete:hover {
|
||
background: #ef4444;
|
||
}
|
||
|
||
.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">
|
||
<div class="claude-chart-wrap" id="claude-chart"></div>
|
||
<span class="status-inline" id="status-items"></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 deliveries"></div>
|
||
<span class="section-title">📦 Deliveries</span>
|
||
</div>
|
||
<span class="section-count" id="deliveries-count">0</span>
|
||
</div>
|
||
<div class="card" id="deliveries-container">
|
||
<div class="empty">No active deliveries</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) {
|
||
const isDone = t.status === 'done';
|
||
const isInProgress = t.status === 'in-progress';
|
||
return `
|
||
<div class="task-item" data-id="${t.id}">
|
||
<div class="task-header">
|
||
<div class="task-checkbox" onclick="toggleTaskDone('${t.id}', '${t.status}')" title="${isDone ? 'Mark pending' : 'Mark done'}">
|
||
${isDone ? '✓' : (isInProgress ? '◐' : '○')}
|
||
</div>
|
||
<div class="task-title" style="${isDone ? 'text-decoration: line-through; opacity: 0.6;' : ''}">${t.title}</div>
|
||
${t.domain ? `<span class="task-domain">${t.domain}</span>` : ''}
|
||
<div class="task-actions">
|
||
${!isDone && !isInProgress ? `<button class="task-btn" onclick="setTaskStatus('${t.id}', 'in-progress')" title="Start">▶</button>` : ''}
|
||
${isInProgress ? `<button class="task-btn" onclick="setTaskStatus('${t.id}', 'pending')" title="Pause">⏸</button>` : ''}
|
||
<button class="task-btn delete" onclick="deleteTask('${t.id}')" title="Delete">×</button>
|
||
</div>
|
||
</div>
|
||
${t.notes ? `<div class="task-notes">${t.notes}</div>` : ''}
|
||
<div class="task-meta">
|
||
${priorityBadge(t.priority)}
|
||
${showOwner ? ownerBadge(t.owner) : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function toggleTaskDone(id, currentStatus) {
|
||
const newStatus = currentStatus === 'done' ? 'pending' : 'done';
|
||
await setTaskStatus(id, newStatus);
|
||
}
|
||
|
||
async function setTaskStatus(id, status) {
|
||
try {
|
||
await fetch(`/api/tasks/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status })
|
||
});
|
||
loadData();
|
||
} catch (e) {
|
||
console.error('Failed to update task:', e);
|
||
}
|
||
}
|
||
|
||
async function deleteTask(id) {
|
||
if (!confirm('Delete this task?')) return;
|
||
try {
|
||
await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
|
||
loadData();
|
||
} catch (e) {
|
||
console.error('Failed to delete task:', e);
|
||
}
|
||
}
|
||
|
||
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>
|
||
`;
|
||
}
|
||
|
||
function renderDelivery(d) {
|
||
const statusLabel = d.status.replace(/_/g, ' ');
|
||
const icon = d.status === 'out_for_delivery' ? '🚚' : (d.status === 'delayed' ? '⚠️' : '📦');
|
||
return `
|
||
<div class="delivery-item">
|
||
<div class="delivery-header">
|
||
<div class="delivery-title">
|
||
${icon} ${d.description}
|
||
</div>
|
||
<span class="delivery-status ${d.status}">${statusLabel}</span>
|
||
</div>
|
||
<div class="delivery-meta">
|
||
${d.carrier}${d.expected_date ? ' • Expected: ' + d.expected_date : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [tasksRes, newsRes, briefingsRes, deliveriesRes] = await Promise.all([
|
||
fetch('/api/tasks'),
|
||
fetch('/api/news'),
|
||
fetch('/api/briefings'),
|
||
fetch('/api/deliveries')
|
||
]);
|
||
|
||
const tasksData = await tasksRes.json();
|
||
const newsData = await newsRes.json();
|
||
const briefingsData = await briefingsRes.json();
|
||
const deliveriesData = await deliveriesRes.json();
|
||
|
||
const tasks = tasksData.tasks || [];
|
||
const news = newsData.items || [];
|
||
const briefings = briefingsData.briefings || [];
|
||
const deliveries = deliveriesData.deliveries || [];
|
||
|
||
// 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 deliveries (active only - API already filters out delivered)
|
||
document.getElementById('deliveries-count').textContent = deliveries.length;
|
||
document.getElementById('deliveries-container').innerHTML = deliveries.length
|
||
? deliveries.map(renderDelivery).join('')
|
||
: '<div class="empty">No active deliveries</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>
|
||
`;
|
||
}
|
||
|
||
} 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>';
|
||
}
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const res = await fetch('/api/status');
|
||
const data = await res.json();
|
||
const items = data.status || {};
|
||
|
||
if (Object.keys(items).length === 0) {
|
||
document.getElementById('status-items').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Custom render order and formatting
|
||
const parts = [];
|
||
|
||
// OpenClaw version — green if "(latest)" or type=info, red if outdated
|
||
if (items.openclaw) {
|
||
const ver = items.openclaw.value.replace(/\s*\(latest\)\s*/g, '').replace(/✅/g, '').trim();
|
||
const isLatest = items.openclaw.type !== 'error';
|
||
const color = isLatest ? 'var(--success)' : 'var(--danger)';
|
||
parts.push(`<span style="color:${color};font-weight:600">OpenClaw ${ver}</span>`);
|
||
}
|
||
|
||
// Services — just the value, no checkmarks
|
||
if (items.services) {
|
||
const svc = items.services.value.replace(/✅/g, '').replace(/\(checked[^)]*\)/g, '').replace(/services/gi, '').trim();
|
||
parts.push(`<span class="value ok">${svc || 'All healthy'}</span>`);
|
||
}
|
||
|
||
// Valley Balance
|
||
if (items.valley_balance) {
|
||
parts.push(`<span class="value ok">Valley ${items.valley_balance.value}</span>`);
|
||
}
|
||
|
||
// Zurich — no checkmarks
|
||
if (items.zurich) {
|
||
const z = items.zurich.value.replace(/✅/g, '').trim();
|
||
parts.push(`<span class="value ok">Zurich ${z}</span>`);
|
||
}
|
||
|
||
document.getElementById('status-items').innerHTML = parts.join(' · ');
|
||
|
||
// Claude usage mini chart
|
||
await loadClaudeChart(items.claude);
|
||
} catch (e) {
|
||
console.error('Failed to load status:', e);
|
||
}
|
||
}
|
||
|
||
async function loadClaudeChart(claudeStatus) {
|
||
const container = document.getElementById('claude-chart');
|
||
if (!container) return;
|
||
|
||
let weeklyPct = 10;
|
||
let resetTime = null;
|
||
let lastUpdated = null;
|
||
let history = [];
|
||
|
||
if (claudeStatus) {
|
||
const m = claudeStatus.value.match(/(\d+)%/);
|
||
if (m) weeklyPct = parseInt(m[1]);
|
||
}
|
||
|
||
// Get usage data + history from API
|
||
try {
|
||
const res = await fetch('/api/claude-usage');
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
if (d.weekly_resets) resetTime = new Date(d.weekly_resets);
|
||
if (d.weekly_percent !== undefined) weeklyPct = d.weekly_percent;
|
||
if (d.last_updated) lastUpdated = d.last_updated;
|
||
if (d.history) history = d.history;
|
||
}
|
||
} catch(e) {}
|
||
|
||
let pctClass = 'ok';
|
||
if (weeklyPct > 70) pctClass = 'warning';
|
||
if (weeklyPct > 85) pctClass = 'error';
|
||
|
||
const W = 160, H = 36, PAD = 2;
|
||
const now = new Date();
|
||
const weekMs = 7 * 86400000;
|
||
|
||
// Week start = reset time - 7 days
|
||
let weekStart;
|
||
if (resetTime) {
|
||
weekStart = new Date(resetTime.getTime() - weekMs);
|
||
} else {
|
||
const d = new Date(now);
|
||
d.setDate(d.getDate() - ((d.getDay() + 2) % 7));
|
||
d.setHours(14,0,0,0);
|
||
weekStart = d;
|
||
}
|
||
const weekEnd = new Date(weekStart.getTime() + weekMs);
|
||
|
||
// Map time to X coordinate
|
||
const timeToX = (t) => {
|
||
const frac = Math.max(0, Math.min(1, (t - weekStart) / weekMs));
|
||
return PAD + frac * (W - PAD*2);
|
||
};
|
||
const pctToY = (p) => H - PAD - (p / 100) * (H - PAD*2);
|
||
|
||
// Day separators
|
||
let dayLines = '';
|
||
for (let d = 1; d < 7; d++) {
|
||
const x = timeToX(new Date(weekStart.getTime() + d * 86400000));
|
||
dayLines += `<line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="#E5E2DE" stroke-width="0.5"/>`;
|
||
}
|
||
|
||
// Budget line (dashed diagonal)
|
||
const bx0 = PAD, by0 = H - PAD;
|
||
const bx1 = W - PAD, by1 = PAD;
|
||
|
||
const color = pctClass === 'ok' ? '#059669' : pctClass === 'warning' ? '#B45309' : '#DC2626';
|
||
|
||
// Build polyline from history points (filter to current week)
|
||
const weekStartMs = weekStart.getTime();
|
||
const points = history
|
||
.filter(h => new Date(h.timestamp).getTime() >= weekStartMs)
|
||
.map(h => ({ t: new Date(h.timestamp), p: h.weekly_percent }))
|
||
.sort((a, b) => a.t - b.t);
|
||
|
||
let pathD = '';
|
||
let lastPoint = null;
|
||
if (points.length > 0) {
|
||
// Start from week start at 0%
|
||
pathD = `M${PAD},${H - PAD}`;
|
||
// First history point
|
||
pathD += ` L${timeToX(points[0].t)},${pctToY(points[0].p)}`;
|
||
for (let i = 1; i < points.length; i++) {
|
||
pathD += ` L${timeToX(points[i].t)},${pctToY(points[i].p)}`;
|
||
}
|
||
lastPoint = points[points.length - 1];
|
||
}
|
||
|
||
// Current position dot
|
||
const nowX = timeToX(now);
|
||
const nowY = pctToY(weeklyPct);
|
||
if (pathD) {
|
||
pathD += ` L${nowX},${nowY}`;
|
||
} else {
|
||
// No history: draw single line from 0 to current
|
||
pathD = `M${PAD},${H - PAD} L${nowX},${nowY}`;
|
||
}
|
||
|
||
const svg = `<svg class="claude-chart-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||
${dayLines}
|
||
<line x1="${bx0}" y1="${by0}" x2="${bx1}" y2="${by1}" stroke="#E5E2DE" stroke-width="1" stroke-dasharray="3,2"/>
|
||
<path d="${pathD}" stroke="${color}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<circle cx="${nowX}" cy="${nowY}" r="2.5" fill="${color}"/>
|
||
</svg>`;
|
||
|
||
// Issue 2: last-pull timestamp
|
||
let updatedLabel = '';
|
||
if (lastUpdated) {
|
||
const updatedDate = new Date(lastUpdated);
|
||
const diffMs = now - updatedDate;
|
||
const diffMin = Math.floor(diffMs / 60000);
|
||
const diffHr = Math.floor(diffMs / 3600000);
|
||
if (diffMs < 60000) updatedLabel = 'just now';
|
||
else if (diffMin < 60) updatedLabel = diffMin + 'm ago';
|
||
else if (diffHr < 24) updatedLabel = diffHr + 'h ago';
|
||
else updatedLabel = updatedDate.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<span class="claude-chart-label">Claude</span>
|
||
<span class="claude-chart-pct ${pctClass}">${weeklyPct}%</span>
|
||
${svg}
|
||
${updatedLabel ? `<span class="claude-chart-label">${updatedLabel}</span>` : ''}
|
||
`;
|
||
}
|
||
|
||
loadAgents();
|
||
loadStatus();
|
||
loadData();
|
||
setInterval(loadData, 30000);
|
||
setInterval(loadStatus, 60000); // Refresh status every minute
|
||
</script>
|
||
</body>
|
||
</html>
|