<script lang="ts">
import { analytics } from "../../stores/analytics.svelte.js";
import LoadingSpinner from "../layout/LoadingSpinner.svelte";
const CELL_SIZE = 17;
const CELL_GAP = 2;
const CELL_STEP = CELL_SIZE + CELL_GAP;
const ROW_LABEL_WIDTH = 29;
const COL_LABEL_HEIGHT = 18;
const DAY_LABELS = [
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun",
];
const LEVEL_COLORS_LIGHT = [
"var(--vscode-input-background)",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
const LEVEL_COLORS_DARK = [
"var(--vscode-input-background)",
"#0e4429",
"#006d32",
"#26a641",
"#39d353",
];
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]!;
}
function assignLevel(value: number, max: number): number {
if (value <= 0) return 0;
if (max <= 0) return 1;
const ratio = value / max;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
}
let tooltip = $state<{
x: number;
y: number;
text: string;
} | null>(null);
const grid = $derived.by(() => {
const cells = analytics.hourOfWeek?.cells;
if (!cells || cells.length === 0) return null;
const lookup = new Map<string, number>();
let max = 0;
for (const c of cells) {
lookup.set(`${c.day_of_week}:${c.hour}`, c.messages);
if (c.messages > max) max = c.messages;
}
const rows: {
day: string;
dayIdx: number;
hours: {
hour: number;
value: number;
level: number;
}[];
}[] = [];
for (let d = 0; d < 7; d++) {
const hours: {
hour: number;
value: number;
level: number;
}[] = [];
for (let h = 0; h < 24; h++) {
const value = lookup.get(`${d}:${h}`) ?? 0;
hours.push({
hour: h,
value,
level: assignLevel(value, max),
});
}
rows.push({ day: DAY_LABELS[d]!, dayIdx: d, hours });
}
return rows;
});
const chartWidth = ROW_LABEL_WIDTH + 24 * CELL_STEP + 4;
const chartHeight = COL_LABEL_HEIGHT + 7 * CELL_STEP + 4;
function handleCellHover(
e: MouseEvent,
day: string,
hour: number,
value: number,
) {
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const h = hour.toString().padStart(2, "0");
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${day} ${h}:00 - ${value.toLocaleString()} messages`,
};
}
function handleCellLeave() {
tooltip = null;
}
function handleCellClick(dow: number, hour: number) {
analytics.selectHourOfWeek(dow, hour);
}
function handleDayClick(dow: number) {
analytics.selectHourOfWeek(dow, null);
}
function handleHourClick(hour: number) {
analytics.selectHourOfWeek(null, hour);
}
function isDimmed(dow: number, hour: number): boolean {
const sd = analytics.selectedDow;
const sh = analytics.selectedHour;
if (sd === null && sh === null) return false;
if (sd !== null && sh !== null) {
return dow !== sd || hour !== sh;
}
if (sd !== null) return dow !== sd;
return hour !== sh;
}
</script>
<div class="how-container">
{#if !analytics.loaded.hourOfWeek || analytics.loading.hourOfWeek}
<LoadingSpinner label="Loading hour-of-week activity" />
{:else if analytics.errors.hourOfWeek}
<div class="error">
{analytics.errors.hourOfWeek}
<button
class="retry-btn"
onclick={() => analytics.fetchHourOfWeek()}
>
Retry
</button>
</div>
{:else if grid}
<div class="how-scroll">
<div
class="how-chart"
style={`width:${chartWidth}px;height:${chartHeight}px;`}
>
{#each [0, 3, 6, 9, 12, 15, 18, 21] as hour}
<button
class="hour-label"
class:active-label={analytics.selectedHour === hour}
style={`left:${hour * CELL_STEP + ROW_LABEL_WIDTH + CELL_SIZE / 2}px;top:${COL_LABEL_HEIGHT - 12}px;`}
onclick={() => handleHourClick(hour)}
>
{hour}
</button>
{/each}
{#each grid as row, rowIdx}
<button
class="day-label"
class:active-label={analytics.selectedDow === row.dayIdx}
style={`left:${ROW_LABEL_WIDTH - 4}px;top:${rowIdx * CELL_STEP + COL_LABEL_HEIGHT + CELL_SIZE - 11}px;`}
onclick={() => handleDayClick(row.dayIdx)}
>
{row.day}
</button>
{#each row.hours as cell}
<button
class="how-cell"
class:dimmed={isDimmed(row.dayIdx, cell.hour)}
style={`left:${cell.hour * CELL_STEP + ROW_LABEL_WIDTH}px;top:${rowIdx * CELL_STEP + COL_LABEL_HEIGHT}px;background:${levelColor(cell.level)};`}
aria-label={`${row.day} ${cell.hour}:00 ${cell.value} messages`}
onmouseenter={(e) =>
handleCellHover(e, row.day, cell.hour, cell.value)}
onmouseleave={handleCellLeave}
onclick={() =>
handleCellClick(row.dayIdx, cell.hour)}
></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>
.how-container {
position: relative;
flex: 1;
}
.how-scroll {
overflow-x: auto;
padding-bottom: var(--wb-spacing-4);
}
.how-chart {
position: relative;
}
.hour-label,
.day-label {
position: absolute;
font-size: var(--wb-font-size-9);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-sans);
cursor: pointer;
white-space: nowrap;
}
.hour-label {
transform: translateX(-50%);
}
.day-label {
transform: translateX(-100%);
}
.hour-label:hover,
.day-label:hover {
color: var(--vscode-editor-foreground);
}
.active-label {
color: var(--vscode-focusBorder);
font-weight: 600;
}
.how-cell {
position: absolute;
width: var(--wb-control-size-17);
height: var(--wb-control-size-17);
border-radius: var(--wb-corner-radius-2);
cursor: pointer;
transition: opacity 0.15s;
}
.how-cell:hover {
opacity: 0.8;
outline: 1px solid var(--vscode-disabledForeground);
}
.how-cell.dimmed {
opacity: 0.2;
}
.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;
}
.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>