1<script lang="ts">
2 import { Chart } from 'frappe-charts';
3 import { onMount } from 'svelte';
4 import type { HourlyDistribution } from '../types';
5
6 interface Props {
7 hourlyDistribution: HourlyDistribution[];
8 }
9
10 let { hourlyDistribution }: Props = $props();
11
12 let container: HTMLDivElement;
13 let chart: Chart | null = null;
14
15 // Get accent color from CSS variable
16 function getAccentColor(): string {
17 return getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#4ade80';
18 }
19
20 // Convert data to Frappe Charts format (ensure all 24 hours)
21 const chartData = $derived.by(() => {
22 // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation
23 const visitsByHour = new Map<number, number>();
24 hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
25
26 const labels = Array.from({ length: 24 }, (_, i) =>
27 i % 6 === 0 ? `${String(i).padStart(2, '0')}h` : ''
28 );
29 const values = Array.from({ length: 24 }, (_, i) => visitsByHour.get(i) ?? 0);
30
31 return {
32 labels,
33 datasets: [{ name: 'Activity', values }]
34 };
35 });
36
37 // Peak hour for display
38 const peakHour = $derived.by(() => {
39 let maxVisits = 0;
40 let peak = 0;
41 hourlyDistribution.forEach(h => {
42 if (h.visit_count > maxVisits) {
43 maxVisits = h.visit_count;
44 peak = h.hour;
45 }
46 });
47 return `${String(peak).padStart(2, '0')}:00`;
48 });
49
50 // Initialize chart on mount
51 onMount(() => {
52 chart = new Chart(container, {
53 data: chartData,
54 type: 'bar',
55 height: 120,
56 colors: [getAccentColor()],
57 axisOptions: { xAxisMode: 'tick' },
58 barOptions: { spaceRatio: 0.3 },
59 tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
60 });
61
62 return () => {
63 chart?.destroy();
64 chart = null;
65 };
66 });
67
68 // Update chart when data changes (after initial mount)
69 $effect(() => {
70 if (!chart) return;
71
72 const data = chartData;
73 if (data.labels.length === 0) return;
74
75 chart.update(data);
76 });
77</script>
78
79<div class="chart-wrapper">
80 <div class="chart-header">
81 <span class="chart-label">Activity</span>
82 <span class="chart-value">peak at {peakHour}</span>
83 </div>
84 <div bind:this={container} class="chart-container"></div>
85</div>
86
87<style>
88 .chart-wrapper {
89 width: 100%;
90 display: flex;
91 flex-direction: column;
92 }
93
94 .chart-header {
95 display: flex;
96 align-items: center;
97 gap: 0.5rem;
98 padding: 0 0.25rem 0.5rem;
99 font-size: 0.65rem;
100 font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
101 }
102
103 .chart-label {
104 color: var(--primary, #4ade80);
105 font-weight: 600;
106 }
107
108 .chart-value {
109 color: var(--muted-foreground, #737373);
110 }
111
112 .chart-container {
113 width: 100%;
114 }
115
116 .chart-container :global(.frappe-chart) {
117 font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
118 }
119
120 .chart-container :global(.frappe-chart .axis text),
121 .chart-container :global(.frappe-chart .chart-label) {
122 fill: var(--muted-foreground, #737373);
123 font-size: 10px;
124 }
125
126 .chart-container :global(.frappe-chart .axis line),
127 .chart-container :global(.frappe-chart .chart-strokes) {
128 stroke: var(--border, #333);
129 }
130</style>