[claude] Workshop Activity Heatmap (24h Job Volume) (#9) #91
@@ -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);
|
||||
|
||||
59
artifacts/api-server/src/routes/stats.ts
Normal file
59
artifacts/api-server/src/routes/stats.ts
Normal file
@@ -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<number>`cast(extract(epoch from date_trunc('hour', created_at)) as bigint)`,
|
||||
count: sql<number>`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<number, number>();
|
||||
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;
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -530,6 +596,18 @@
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
<div id="event-log"></div>
|
||||
|
||||
<!-- ── Activity heatmap (#9) ──────────────────────────────────────── -->
|
||||
<div id="activity-heatmap">
|
||||
<div id="heatmap-bar"></div>
|
||||
<button id="heatmap-icon-btn" title="Show activity heatmap">▦</button>
|
||||
</div>
|
||||
<div id="heatmap-tooltip"></div>
|
||||
<div id="heatmap-overlay">
|
||||
<div id="heatmap-overlay-title">24H ACTIVITY</div>
|
||||
<div id="heatmap-overlay-bar"></div>
|
||||
<button id="heatmap-overlay-close">CLOSE</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Timmy identity card ────────────────────────────────────────── -->
|
||||
<div id="timmy-id-card">
|
||||
<div class="id-label">TIMMY IDENTITY</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user