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>
158 lines
3.6 KiB
Svelte
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>
|