diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index ff057e1..1d404e4 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -17,11 +17,13 @@ import relayRouter from "./relay.js"; import adminRelayRouter from "./admin-relay.js"; import adminRelayQueueRouter from "./admin-relay-queue.js"; import geminiRouter from "./gemini.js"; +import statsRouter from "./stats.js"; const router: IRouter = Router(); router.use(healthRouter); router.use(metricsRouter); +router.use(statsRouter); router.use(jobsRouter); router.use(estimateRouter); router.use(bootstrapRouter); diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts new file mode 100644 index 0000000..8b8cc83 --- /dev/null +++ b/artifacts/api-server/src/routes/stats.ts @@ -0,0 +1,59 @@ +import { Router, type Request, type Response } from "express"; +import { db, jobs } from "@workspace/db"; +import { sql, gte } from "drizzle-orm"; +import { makeLogger } from "../lib/logger.js"; + +const router = Router(); +const logger = makeLogger("stats"); + +/** + * GET /api/stats/activity + * + * Returns job counts bucketed by hour for the past 24 hours. + * Each bucket represents a UTC hour (0–23). + * Hours with no activity are included as 0. + * + * Response shape: + * { hours: number[24], generatedAt: string } + * hours[0] = oldest hour (24h ago), hours[23] = current hour + */ +router.get("/api/stats/activity", async (_req: Request, res: Response) => { + try { + const now = new Date(); + const windowStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Count completed jobs grouped by the hour they were created, + // within the last 24h window. + const rows = await db + .select({ + hour: sql`cast(extract(epoch from date_trunc('hour', created_at)) as bigint)`, + count: sql`cast(count(*) as int)`, + }) + .from(jobs) + .where(gte(jobs.createdAt, windowStart)) + .groupBy(sql`date_trunc('hour', created_at)`); + + // Build a map: epoch-hour → count + const byEpochHour = new Map(); + for (const row of rows) { + byEpochHour.set(Number(row.hour), Number(row.count)); + } + + // Build 24-slot array aligned to whole hours, oldest first. + // slot 0 = floor(now - 24h), slot 23 = floor(now) + const currentHourEpoch = Math.floor(now.getTime() / (3600 * 1000)) * 3600; + const hours: number[] = []; + for (let i = 23; i >= 0; i--) { + const slotEpoch = currentHourEpoch - i * 3600; + hours.push(byEpochHour.get(slotEpoch) ?? 0); + } + + res.json({ hours, generatedAt: now.toISOString() }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch activity stats"; + logger.error("activity stats failed", { error: message }); + res.status(500).json({ error: message }); + } +}); + +export default router; diff --git a/the-matrix/index.html b/the-matrix/index.html index a58cfea..6055915 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -514,6 +514,72 @@ } #timmy-id-card .id-npub:hover { color: #88aadd; } #timmy-id-card .id-zaps { color: #556688; font-size: 9px; } + + /* ── Activity heatmap (#9) ────────────────────────────────────────── */ + #activity-heatmap { + position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); + z-index: 10; pointer-events: all; + } + #heatmap-bar { + display: flex; gap: 2px; align-items: flex-end; + } + .hm-seg { + width: 10px; height: 18px; border-radius: 1px; + background: #111122; + cursor: pointer; + transition: transform 0.1s; + flex-shrink: 0; + } + .hm-seg:hover { transform: scaleY(1.3); } + @keyframes hm-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 4px currentColor; } + 50% { opacity: 0.5; box-shadow: none; } + } + .hm-seg-current { animation: hm-pulse 2s ease-in-out infinite; } + #heatmap-icon-btn { + display: none; + background: rgba(20, 16, 36, 0.88); + border: 1px solid #2a2a44; + color: #5588bb; + font-family: 'Courier New', monospace; + font-size: 16px; padding: 6px 10px; + cursor: pointer; border-radius: 3px; + } + #heatmap-tooltip { + position: fixed; display: none; + background: rgba(5,3,12,0.92); border: 1px solid #2a2a44; + color: #aabbdd; font-family: 'Courier New', monospace; + font-size: 10px; padding: 3px 8px; border-radius: 2px; + pointer-events: none; z-index: 50; + white-space: nowrap; + } + /* Mobile overlay */ + #heatmap-overlay { + display: none; position: fixed; inset: 0; + background: rgba(5,3,12,0.97); z-index: 100; + flex-direction: column; align-items: center; justify-content: center; + gap: 16px; + } + #heatmap-overlay.open { display: flex; } + #heatmap-overlay-title { + color: #7799cc; font-family: 'Courier New', monospace; + font-size: 12px; letter-spacing: 3px; + } + #heatmap-overlay-bar { + display: flex; gap: 4px; align-items: flex-end; flex-wrap: wrap; + justify-content: center; max-width: 90vw; + } + #heatmap-overlay-bar .hm-seg { width: 14px; height: 28px; } + #heatmap-overlay-close { + background: transparent; border: 1px solid #2a2a44; + color: #5588bb; font-family: 'Courier New', monospace; + font-size: 11px; padding: 6px 16px; cursor: pointer; + letter-spacing: 1px; border-radius: 2px; + } + @media (max-width: 600px) { + #activity-heatmap #heatmap-bar { display: none; } + #heatmap-icon-btn { display: block; } + } @@ -530,6 +596,18 @@
OFFLINE
+ +
+
+ +
+
+
+
24H ACTIVITY
+
+ +
+
TIMMY IDENTITY
diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 70f293e..218398b 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -186,6 +186,7 @@ export function initUI() { if (uiInitialized) return; uiInitialized = true; initInputBar(); + initHeatmap(); } function initInputBar() { @@ -305,3 +306,119 @@ export function appendDebateMessage(agent, argument, isVerdict, accepted) { export function loadChatHistory() { return []; } export function saveChatHistory() {} + +// ── Activity heatmap (#9) ───────────────────────────────────────────────────── +// Fetches /api/stats/activity and renders a 24-segment heatmap. +// Auto-refreshes every 5 minutes. On mobile, collapses to an icon that opens +// a full-screen overlay. + +const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 minutes +let _heatmapTimer = null; +let _lastHours = null; // number[24] cached for overlay re-render + +/** Convert an hour index (0 = oldest, 23 = current) to a UTC hour label like "3pm" or "midnight". */ +function _hourLabel(hourIndex) { + const now = new Date(); + const currentHour = now.getUTCHours(); + // slot 23 = current UTC hour, slot 0 = 23 hours ago + const h = ((currentHour - (23 - hourIndex)) % 24 + 24) % 24; + if (h === 0) return 'midnight'; + if (h === 12) return 'noon'; + return h < 12 ? `${h}am` : `${h - 12}pm`; +} + +/** Interpolate from dim blue (#111133) to bright blue-white (#88ccff) based on 0–1 intensity. */ +function _segmentColor(intensity) { + // dim: [17, 17, 51] bright: [136, 204, 255] + const r = Math.round(17 + (136 - 17) * intensity); + const g = Math.round(17 + (204 - 17) * intensity); + const b = Math.round(51 + (255 - 51) * intensity); + return `rgb(${r},${g},${b})`; +} + +function _renderSegments(hours, container, isMobile) { + container.innerHTML = ''; + const max = Math.max(...hours, 1); // avoid div-by-zero + const currentSlot = 23; + + hours.forEach((count, i) => { + const seg = document.createElement('div'); + seg.className = 'hm-seg' + (i === currentSlot ? ' hm-seg-current' : ''); + const intensity = count / max; + const color = _segmentColor(intensity); + seg.style.background = color; + if (i === currentSlot) seg.style.color = color; // used by pulse animation + seg.dataset.index = String(i); + seg.dataset.count = String(count); + if (isMobile) { + seg.style.width = '14px'; + seg.style.height = '28px'; + } + container.appendChild(seg); + }); +} + +function _initHeatmapTooltip(barEl) { + const $tip = document.getElementById('heatmap-tooltip'); + if (!$tip) return; + + barEl.addEventListener('mousemove', e => { + const seg = e.target.closest('.hm-seg'); + if (!seg) { $tip.style.display = 'none'; return; } + const i = Number(seg.dataset.index); + const count = Number(seg.dataset.count); + const label = _hourLabel(i); + $tip.textContent = `${label}: ${count} job${count !== 1 ? 's' : ''} submitted`; + $tip.style.display = 'block'; + $tip.style.left = `${e.clientX + 10}px`; + $tip.style.top = `${e.clientY - 24}px`; + }); + + barEl.addEventListener('mouseleave', () => { $tip.style.display = 'none'; }); +} + +async function _fetchAndRenderHeatmap() { + try { + const res = await fetch('/api/stats/activity'); + if (!res.ok) return; + const data = await res.json(); + const hours = Array.isArray(data.hours) ? data.hours : []; + if (hours.length !== 24) return; + _lastHours = hours; + + const $bar = document.getElementById('heatmap-bar'); + if ($bar) _renderSegments(hours, $bar, false); + + const $overlayBar = document.getElementById('heatmap-overlay-bar'); + if ($overlayBar) _renderSegments(hours, $overlayBar, true); + } catch { + // silently ignore fetch errors + } +} + +export function initHeatmap() { + const $bar = document.getElementById('heatmap-bar'); + const $iconBtn = document.getElementById('heatmap-icon-btn'); + const $overlay = document.getElementById('heatmap-overlay'); + const $closeBtn = document.getElementById('heatmap-overlay-close'); + + if ($bar) _initHeatmapTooltip($bar); + + if ($iconBtn && $overlay) { + $iconBtn.addEventListener('click', () => { + $overlay.classList.add('open'); + if (_lastHours) { + const $overlayBar = document.getElementById('heatmap-overlay-bar'); + if ($overlayBar) _renderSegments(_lastHours, $overlayBar, true); + } + }); + } + + if ($closeBtn && $overlay) { + $closeBtn.addEventListener('click', () => $overlay.classList.remove('open')); + } + + // Initial fetch then schedule refresh + void _fetchAndRenderHeatmap(); + _heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS); +}