toknmtr-selfhost/src/lib/components/dashboard/Punchcard.svelte
megaproxy 71a60ab054 toknmtr — self-hostable Claude Code usage & analytics dashboard
Server (SvelteKit + SQLite Docker container) + per-machine agent that parses
Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript
view + search gated behind SHOW_TRANSCRIPTS (hidden by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:33:55 +01:00

158 lines
3.6 KiB
Svelte

<script lang="ts">
import { fmtInt, fmtCompact, fmtUsd } from '$lib/format';
import type { Punchcard } from '$lib/server/stats/punchcard';
interface Props {
punchcard: Punchcard;
metric?: 'events' | 'tokens';
}
let { punchcard, metric = 'events' }: Props = $props();
// Row labels, Monday-first to match the server's dow index (0=Mon..6=Sun).
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const hours = Array.from({ length: 24 }, (_, h) => h);
const tickHours = [0, 6, 12, 18, 23];
const cells = $derived(punchcard.cells);
const maxValue = $derived(
metric === 'events'
? Math.max(1, punchcard.maxEventCount)
: Math.max(1, punchcard.maxTotalTokens)
);
const hasData = $derived(punchcard.totalEvents > 0);
function cellValue(dow: number, hour: number): number {
const c = cells[dow][hour];
return metric === 'events' ? c.eventCount : c.totalTokens;
}
// Intensity 0..1 → opacity of the accent fill. sqrt gives lighter cells more
// visible contrast at the low end.
function intensity(dow: number, hour: number): number {
const v = cellValue(dow, hour);
if (v <= 0) return 0;
return Math.sqrt(v / maxValue);
}
function hourLabel(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`;
}
</script>
{#if !hasData}
<p class="empty">No data in this window.</p>
{:else}
<div class="punch">
<div class="grid" role="img" aria-label="Activity by day of week and hour of day (UTC)">
{#each DAY_LABELS as day, dow (day)}
<span class="day-label mono">{day}</span>
{#each hours as hour (hour)}
{@const cell = cells[dow][hour]}
{@const op = intensity(dow, hour)}
<span
class="cell"
class:zero={op === 0}
style={`--op:${op}`}
title={`${day} ${hourLabel(hour)} UTC · ${fmtInt(cell.eventCount)} events · ${fmtCompact(cell.totalTokens)} tok · ${fmtUsd(cell.costUsd)}`}
></span>
{/each}
{/each}
<span class="corner"></span>
{#each hours as hour (hour)}
<span class="hour-tick mono">{tickHours.includes(hour) ? hour : ''}</span>
{/each}
</div>
<div class="legend">
<span>Less</span>
<span class="swatch" style="--op:0.1"></span>
<span class="swatch" style="--op:0.4"></span>
<span class="swatch" style="--op:0.7"></span>
<span class="swatch" style="--op:1"></span>
<span>More</span>
<span class="utc-note mono">UTC</span>
</div>
</div>
{/if}
<style>
.punch {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.grid {
display: grid;
/* day label column + 24 hour columns */
grid-template-columns: auto repeat(24, 1fr);
gap: 3px;
align-items: center;
}
.day-label {
font-size: 0.7rem;
color: var(--text-faint);
text-align: right;
padding-right: 0.5rem;
white-space: nowrap;
}
.cell {
aspect-ratio: 1 / 1;
border-radius: 3px;
background: var(--accent);
opacity: var(--op);
min-height: 12px;
transition: transform 0.12s ease;
}
.cell:hover {
transform: scale(1.18);
}
.cell.zero {
background: var(--bg-raised);
opacity: 1;
}
.corner {
/* aligns with the day-label column under the grid */
display: block;
}
.hour-tick {
font-size: 0.62rem;
color: var(--text-faint);
text-align: center;
}
.legend {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--text-faint);
}
.swatch {
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--accent);
opacity: var(--op);
}
.utc-note {
margin-left: auto;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 24px 0;
text-align: center;
}
</style>