<script lang="ts">
import { analytics } from "../../stores/analytics.svelte.js";
import LoadingSpinner from "../layout/LoadingSpinner.svelte";
const CELL_SIZE = 16;
const CELL_GAP = 2;
const CELL_STEP = CELL_SIZE + CELL_GAP;
const LABEL_WIDTH = 36;
const HEADER_HEIGHT = 16;
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
const LEVEL_COLORS_LIGHT = [
"var(--vscode-input-background)",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
const LEVEL_COLORS_DARK = [
"var(--vscode-input-background)",
"#0e4429",
"#006d32",
"#26a641",
"#39d353",
];
interface DayCell {
date: string;
value: number;
level: number;
dayOfWeek: number;
}
function levelColor(level: number): string {
const isDark = document.documentElement.classList.contains(
"dark",
);
const colors = isDark ? LEVEL_COLORS_DARK : LEVEL_COLORS_LIGHT;
return colors[level] ?? colors[0]!;
}
let tooltip = $state<{
x: number;
y: number;
text: string;
} | null>(null);
function handleCellHover(
e: MouseEvent,
cell: DayCell,
) {
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const d = new Date(cell.date + "T00:00:00");
const label = d.toLocaleDateString("en", {
month: "short",
day: "numeric",
year: "numeric",
});
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${label}: ${cell.value.toLocaleString()} ${analytics.metric}`,
};
}
function handleCellLeave() {
tooltip = null;
}
function handleCellClick(cell: DayCell) {
analytics.selectDate(cell.date);
}
const grid = $derived.by(() => {
const entries = analytics.heatmap?.entries;
if (!entries || entries.length === 0) {
return { cols: [] as DayCell[][], months: [] as { col: number; label: string }[] };
}
const cols: DayCell[][] = [];
let currentCol: DayCell[] = [];
let lastMonth = "";
const monthLabels: { col: number; label: string }[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]!;
const d = new Date(entry.date + "T00:00:00");
const dow = d.getDay();
const cell: DayCell = {
date: entry.date,
value: entry.value,
level: entry.level,
dayOfWeek: dow,
};
if (i > 0 && dow === 0) {
cols.push(currentCol);
currentCol = [];
}
const month = d.toLocaleString("en", { month: "short" });
if (month !== lastMonth && dow <= 3) {
monthLabels.push({ col: cols.length, label: month });
lastMonth = month;
}
currentCol.push(cell);
}
if (currentCol.length > 0) {
cols.push(currentCol);
}
return { cols, months: monthLabels };
});
const chartWidth = $derived(
grid.cols.length * CELL_STEP + LABEL_WIDTH + 4,
);
const chartHeight = 7 * CELL_STEP + HEADER_HEIGHT + 4;
</script>
<div class="heatmap-container">
<div class="heatmap-header">
<h3 class="chart-title">Activity</h3>
<div class="metric-toggle">
<button
class="toggle-btn"
class:active={analytics.metric === "messages"}
onclick={() => analytics.setMetric("messages")}
>
Messages
</button>
<button
class="toggle-btn"
class:active={analytics.metric === "sessions"}
onclick={() => analytics.setMetric("sessions")}
>
Sessions
</button>
</div>
</div>
{#if !analytics.loaded.heatmap || analytics.loading.heatmap}
<LoadingSpinner label="Loading heatmap" />
{:else if analytics.errors.heatmap}
<div class="error">
{analytics.errors.heatmap}
<button
class="retry-btn"
onclick={() => analytics.fetchHeatmap()}
>
Retry
</button>
</div>
{:else if grid.cols.length > 0}
{#if analytics.heatmap?.entries_from && analytics.heatmap.entries_from > analytics.from}
<div class="clamp-note">Showing most recent year</div>
{/if}
<div class="heatmap-scroll">
<div
class="heatmap-chart"
style={`width:${chartWidth}px;height:${chartHeight}px;`}
>
{#each DAY_LABELS as label, i}
{#if label}
<span
class="day-label"
style={`left:${LABEL_WIDTH - 4}px;top:${i * CELL_STEP + HEADER_HEIGHT + CELL_SIZE - 10}px;`}
>
{label}
</span>
{/if}
{/each}
{#each grid.months as month}
<span
class="month-label"
style={`left:${month.col * CELL_STEP + LABEL_WIDTH}px;top:${HEADER_HEIGHT - 12}px;`}
>
{month.label}
</span>
{/each}
{#each grid.cols as col, colIdx}
{#each col as cell}
<button
class="heatmap-cell"
class:clickable={cell.value > 0 || analytics.selectedDate === cell.date}
class:selected={analytics.selectedDate === cell.date}
style={`left:${colIdx * CELL_STEP + LABEL_WIDTH}px;top:${cell.dayOfWeek * CELL_STEP + HEADER_HEIGHT}px;background:${levelColor(cell.level)};`}
tabindex="-1"
aria-label={`${cell.date}: ${cell.value} ${analytics.metric}`}
onmouseenter={(e) => handleCellHover(e, cell)}
onmouseleave={handleCellLeave}
onclick={() => {
if (cell.value > 0 || analytics.selectedDate === cell.date)
handleCellClick(cell);
}}
onkeydown={(e) => {
if (e.key === "Enter" && (cell.value > 0 || analytics.selectedDate === cell.date))
handleCellClick(cell);
}}
></button>
{/each}
{/each}
</div>
</div>
{#if tooltip}
<div
class="tooltip"
style="left: {tooltip.x}px; top: {tooltip.y}px;"
>
{tooltip.text}
</div>
{/if}
{:else}
<div class="empty">No data for this period</div>
{/if}
</div>
<style>
.heatmap-container {
position: relative;
flex: 1;
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--wb-spacing-8);
}
.chart-title {
font-size: var(--wb-font-size-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.metric-toggle {
display: flex;
gap: var(--wb-spacing-2);
}
.toggle-btn {
height: var(--wb-control-size-22);
padding: 0 var(--wb-spacing-8);
border-radius: var(--wsb-radius-sm);
font-size: var(--wb-font-size-10);
font-weight: 500;
color: var(--vscode-disabledForeground);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.toggle-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.toggle-btn.active {
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
}
.heatmap-scroll {
overflow-x: auto;
padding-bottom: var(--wb-spacing-4);
}
.heatmap-chart {
position: relative;
margin: 0 auto;
}
.day-label,
.month-label {
position: absolute;
font-size: var(--wb-font-size-9);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-sans);
white-space: nowrap;
}
.day-label {
transform: translateX(-100%);
}
.heatmap-cell {
position: absolute;
width: var(--wb-control-size-16);
height: var(--wb-control-size-16);
border-radius: var(--wb-corner-radius-2);
cursor: default;
}
.heatmap-cell.clickable {
cursor: pointer;
}
.heatmap-cell.clickable:hover {
opacity: 0.8;
outline: 1px solid var(--vscode-disabledForeground);
}
.heatmap-cell.selected {
outline: 2px solid var(--vscode-editor-foreground);
}
.tooltip {
position: fixed;
transform: translateX(-50%) translateY(-100%);
padding: var(--wb-spacing-4) var(--wb-spacing-8);
background: var(--vscode-editor-foreground);
color: var(--vscode-editor-background);
font-size: var(--wb-font-size-10);
border-radius: var(--wsb-radius-sm);
white-space: nowrap;
pointer-events: none;
z-index: 100;
}
.clamp-note {
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-10);
margin-bottom: var(--wb-spacing-4);
}
.empty {
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-12);
padding: var(--wb-spacing-24);
text-align: center;
}
.error {
color: var(--vscode-errorForeground);
font-size: var(--wb-font-size-12);
padding: var(--wb-spacing-12);
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
.retry-btn {
padding: var(--wb-spacing-2) var(--wb-spacing-8);
border: 1px solid currentColor;
border-radius: var(--wsb-radius-sm);
font-size: var(--wb-font-size-11);
color: inherit;
cursor: pointer;
}
</style>