james-dashboard/index.html

983 lines
31 KiB
HTML
Raw 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>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>