detached 1eb5fcd

feat(analytics): add frappe-charts components and type definitions

commit
1eb5fcd572670301bc3898ac0b3de4d4d57514e6
author
bnema · · 2025-12-06 20:53 UTC
parent
377b106
signature
unsigned
4 files · +292 -0
A gui/src/pages/homepage/analytics/DailyVisitsChart.svelte +119 -0
@@ -0,0 +1,119 @@
1+<script lang="ts">
2+ import { Chart } from 'frappe-charts';
3+ import { onMount } from 'svelte';
4+ import type { DailyVisitCount } from '../types';
5+
6+ interface Props {
7+ dailyVisits: DailyVisitCount[];
8+ }
9+
10+ let { dailyVisits }: Props = $props();
11+
12+ let container: HTMLDivElement;
13+ let chart: Chart | null = null;
14+
15+ // Convert data to Frappe Charts format
16+ const chartData = $derived.by(() => {
17+ if (!dailyVisits || dailyVisits.length === 0) {
18+ return { labels: [], datasets: [{ values: [] }] };
19+ }
20+
21+ const labels = dailyVisits.map(d => {
22+ const date = new Date(d.day + 'T12:00:00');
23+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
24+ });
25+ const values = dailyVisits.map(d => d.visits);
26+
27+ return {
28+ labels,
29+ datasets: [{ name: 'Visits', values }]
30+ };
31+ });
32+
33+ const totalVisits = $derived(dailyVisits.reduce((sum, d) => sum + d.visits, 0));
34+
35+ // Initialize chart on mount
36+ onMount(() => {
37+ if (chartData.labels.length === 0) return;
38+
39+ chart = new Chart(container, {
40+ data: chartData,
41+ type: 'line',
42+ height: 160,
43+ colors: ['#4ade80'],
44+ axisOptions: { xAxisMode: 'tick', xIsSeries: true },
45+ lineOptions: { regionFill: 1, hideDots: 0, dotSize: 4 },
46+ tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
47+ });
48+
49+ return () => {
50+ chart?.destroy();
51+ chart = null;
52+ };
53+ });
54+
55+ // Update chart when data changes (after initial mount)
56+ $effect(() => {
57+ // Skip if chart not initialized yet or no data
58+ if (!chart) return;
59+
60+ // Read chartData to track changes
61+ const data = chartData;
62+ if (data.labels.length === 0) return;
63+
64+ chart.update(data);
65+ });
66+</script>
67+
68+<div class="chart-wrapper">
69+ <div class="chart-header">
70+ <span class="chart-label">Visits</span>
71+ <span class="chart-value">{totalVisits} total</span>
72+ </div>
73+ <div bind:this={container} class="chart-container"></div>
74+</div>
75+
76+<style>
77+ .chart-wrapper {
78+ width: 100%;
79+ display: flex;
80+ flex-direction: column;
81+ }
82+
83+ .chart-header {
84+ display: flex;
85+ align-items: center;
86+ gap: 0.5rem;
87+ padding: 0 0.25rem 0.5rem;
88+ font-size: 0.65rem;
89+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
90+ }
91+
92+ .chart-label {
93+ color: #4ade80;
94+ font-weight: 600;
95+ }
96+
97+ .chart-value {
98+ color: var(--dynamic-muted, #737373);
99+ }
100+
101+ .chart-container {
102+ width: 100%;
103+ }
104+
105+ .chart-container :global(.frappe-chart) {
106+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
107+ }
108+
109+ .chart-container :global(.frappe-chart .axis text),
110+ .chart-container :global(.frappe-chart .chart-label) {
111+ fill: #737373;
112+ font-size: 10px;
113+ }
114+
115+ .chart-container :global(.frappe-chart .axis line),
116+ .chart-container :global(.frappe-chart .chart-strokes) {
117+ stroke: #333;
118+ }
119+</style>
@@ -0,0 +1,119 @@
1+<script lang="ts">
2+ import { Chart } from 'frappe-charts';
3+ import { onMount } from 'svelte';
4+ import type { DailyVisitCount } from '../types';
5+
6+ interface Props {
7+ dailyVisits: DailyVisitCount[];
8+ }
9+
10+ let { dailyVisits }: Props = $props();
11+
12+ let container: HTMLDivElement;
13+ let chart: Chart | null = null;
14+
15+ // Convert data to Frappe Charts format
16+ const chartData = $derived.by(() => {
17+ if (!dailyVisits || dailyVisits.length === 0) {
18+ return { labels: [], datasets: [{ values: [] }] };
19+ }
20+
21+ const labels = dailyVisits.map(d => {
22+ const date = new Date(d.day + 'T12:00:00');
23+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
24+ });
25+ const values = dailyVisits.map(d => d.visits);
26+
27+ return {
28+ labels,
29+ datasets: [{ name: 'Visits', values }]
30+ };
31+ });
32+
33+ const totalVisits = $derived(dailyVisits.reduce((sum, d) => sum + d.visits, 0));
34+
35+ // Initialize chart on mount
36+ onMount(() => {
37+ if (chartData.labels.length === 0) return;
38+
39+ chart = new Chart(container, {
40+ data: chartData,
41+ type: 'line',
42+ height: 160,
43+ colors: ['#4ade80'],
44+ axisOptions: { xAxisMode: 'tick', xIsSeries: true },
45+ lineOptions: { regionFill: 1, hideDots: 0, dotSize: 4 },
46+ tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
47+ });
48+
49+ return () => {
50+ chart?.destroy();
51+ chart = null;
52+ };
53+ });
54+
55+ // Update chart when data changes (after initial mount)
56+ $effect(() => {
57+ // Skip if chart not initialized yet or no data
58+ if (!chart) return;
59+
60+ // Read chartData to track changes
61+ const data = chartData;
62+ if (data.labels.length === 0) return;
63+
64+ chart.update(data);
65+ });
66+</script>
67+
68+<div class="chart-wrapper">
69+ <div class="chart-header">
70+ <span class="chart-label">Visits</span>
71+ <span class="chart-value">{totalVisits} total</span>
72+ </div>
73+ <div bind:this={container} class="chart-container"></div>
74+</div>
75+
76+<style>
77+ .chart-wrapper {
78+ width: 100%;
79+ display: flex;
80+ flex-direction: column;
81+ }
82+
83+ .chart-header {
84+ display: flex;
85+ align-items: center;
86+ gap: 0.5rem;
87+ padding: 0 0.25rem 0.5rem;
88+ font-size: 0.65rem;
89+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
90+ }
91+
92+ .chart-label {
93+ color: #4ade80;
94+ font-weight: 600;
95+ }
96+
97+ .chart-value {
98+ color: var(--dynamic-muted, #737373);
99+ }
100+
101+ .chart-container {
102+ width: 100%;
103+ }
104+
105+ .chart-container :global(.frappe-chart) {
106+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
107+ }
108+
109+ .chart-container :global(.frappe-chart .axis text),
110+ .chart-container :global(.frappe-chart .chart-label) {
111+ fill: #737373;
112+ font-size: 10px;
113+ }
114+
115+ .chart-container :global(.frappe-chart .axis line),
116+ .chart-container :global(.frappe-chart .chart-strokes) {
117+ stroke: #333;
118+ }
119+</style>
A gui/src/pages/homepage/analytics/HourlyActivityChart.svelte +124 -0
@@ -0,0 +1,124 @@
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+ // Convert data to Frappe Charts format (ensure all 24 hours)
16+ const chartData = $derived.by(() => {
17+ const visitsByHour = new Map<number, number>();
18+ hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
19+
20+ const labels = Array.from({ length: 24 }, (_, i) =>
21+ i % 6 === 0 ? `${String(i).padStart(2, '0')}h` : ''
22+ );
23+ const values = Array.from({ length: 24 }, (_, i) => visitsByHour.get(i) ?? 0);
24+
25+ return {
26+ labels,
27+ datasets: [{ name: 'Activity', values }]
28+ };
29+ });
30+
31+ // Peak hour for display
32+ const peakHour = $derived.by(() => {
33+ let maxVisits = 0;
34+ let peak = 0;
35+ hourlyDistribution.forEach(h => {
36+ if (h.visit_count > maxVisits) {
37+ maxVisits = h.visit_count;
38+ peak = h.hour;
39+ }
40+ });
41+ return `${String(peak).padStart(2, '0')}:00`;
42+ });
43+
44+ // Initialize chart on mount
45+ onMount(() => {
46+ chart = new Chart(container, {
47+ data: chartData,
48+ type: 'bar',
49+ height: 120,
50+ colors: ['#4ade80'],
51+ axisOptions: { xAxisMode: 'tick' },
52+ barOptions: { spaceRatio: 0.3 },
53+ tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
54+ });
55+
56+ return () => {
57+ chart?.destroy();
58+ chart = null;
59+ };
60+ });
61+
62+ // Update chart when data changes (after initial mount)
63+ $effect(() => {
64+ if (!chart) return;
65+
66+ const data = chartData;
67+ if (data.labels.length === 0) return;
68+
69+ chart.update(data);
70+ });
71+</script>
72+
73+<div class="chart-wrapper">
74+ <div class="chart-header">
75+ <span class="chart-label">Activity</span>
76+ <span class="chart-value">peak at {peakHour}</span>
77+ </div>
78+ <div bind:this={container} class="chart-container"></div>
79+</div>
80+
81+<style>
82+ .chart-wrapper {
83+ width: 100%;
84+ display: flex;
85+ flex-direction: column;
86+ }
87+
88+ .chart-header {
89+ display: flex;
90+ align-items: center;
91+ gap: 0.5rem;
92+ padding: 0 0.25rem 0.5rem;
93+ font-size: 0.65rem;
94+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
95+ }
96+
97+ .chart-label {
98+ color: #4ade80;
99+ font-weight: 600;
100+ }
101+
102+ .chart-value {
103+ color: var(--dynamic-muted, #737373);
104+ }
105+
106+ .chart-container {
107+ width: 100%;
108+ }
109+
110+ .chart-container :global(.frappe-chart) {
111+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
112+ }
113+
114+ .chart-container :global(.frappe-chart .axis text),
115+ .chart-container :global(.frappe-chart .chart-label) {
116+ fill: #737373;
117+ font-size: 10px;
118+ }
119+
120+ .chart-container :global(.frappe-chart .axis line),
121+ .chart-container :global(.frappe-chart .chart-strokes) {
122+ stroke: #333;
123+ }
124+</style>
@@ -0,0 +1,124 @@
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+ // Convert data to Frappe Charts format (ensure all 24 hours)
16+ const chartData = $derived.by(() => {
17+ const visitsByHour = new Map<number, number>();
18+ hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
19+
20+ const labels = Array.from({ length: 24 }, (_, i) =>
21+ i % 6 === 0 ? `${String(i).padStart(2, '0')}h` : ''
22+ );
23+ const values = Array.from({ length: 24 }, (_, i) => visitsByHour.get(i) ?? 0);
24+
25+ return {
26+ labels,
27+ datasets: [{ name: 'Activity', values }]
28+ };
29+ });
30+
31+ // Peak hour for display
32+ const peakHour = $derived.by(() => {
33+ let maxVisits = 0;
34+ let peak = 0;
35+ hourlyDistribution.forEach(h => {
36+ if (h.visit_count > maxVisits) {
37+ maxVisits = h.visit_count;
38+ peak = h.hour;
39+ }
40+ });
41+ return `${String(peak).padStart(2, '0')}:00`;
42+ });
43+
44+ // Initialize chart on mount
45+ onMount(() => {
46+ chart = new Chart(container, {
47+ data: chartData,
48+ type: 'bar',
49+ height: 120,
50+ colors: ['#4ade80'],
51+ axisOptions: { xAxisMode: 'tick' },
52+ barOptions: { spaceRatio: 0.3 },
53+ tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
54+ });
55+
56+ return () => {
57+ chart?.destroy();
58+ chart = null;
59+ };
60+ });
61+
62+ // Update chart when data changes (after initial mount)
63+ $effect(() => {
64+ if (!chart) return;
65+
66+ const data = chartData;
67+ if (data.labels.length === 0) return;
68+
69+ chart.update(data);
70+ });
71+</script>
72+
73+<div class="chart-wrapper">
74+ <div class="chart-header">
75+ <span class="chart-label">Activity</span>
76+ <span class="chart-value">peak at {peakHour}</span>
77+ </div>
78+ <div bind:this={container} class="chart-container"></div>
79+</div>
80+
81+<style>
82+ .chart-wrapper {
83+ width: 100%;
84+ display: flex;
85+ flex-direction: column;
86+ }
87+
88+ .chart-header {
89+ display: flex;
90+ align-items: center;
91+ gap: 0.5rem;
92+ padding: 0 0.25rem 0.5rem;
93+ font-size: 0.65rem;
94+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
95+ }
96+
97+ .chart-label {
98+ color: #4ade80;
99+ font-weight: 600;
100+ }
101+
102+ .chart-value {
103+ color: var(--dynamic-muted, #737373);
104+ }
105+
106+ .chart-container {
107+ width: 100%;
108+ }
109+
110+ .chart-container :global(.frappe-chart) {
111+ font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
112+ }
113+
114+ .chart-container :global(.frappe-chart .axis text),
115+ .chart-container :global(.frappe-chart .chart-label) {
116+ fill: #737373;
117+ font-size: 10px;
118+ }
119+
120+ .chart-container :global(.frappe-chart .axis line),
121+ .chart-container :global(.frappe-chart .chart-strokes) {
122+ stroke: #333;
123+ }
124+</style>
A gui/src/pages/homepage/analytics/index.ts +2 -0
@@ -0,0 +1,2 @@
1+export { default as DailyVisitsChart } from './DailyVisitsChart.svelte';
2+export { default as HourlyActivityChart } from './HourlyActivityChart.svelte';
@@ -0,0 +1,2 @@
1+export { default as DailyVisitsChart } from './DailyVisitsChart.svelte';
2+export { default as HourlyActivityChart } from './HourlyActivityChart.svelte';
A gui/src/types/frappe-charts.d.ts +47 -0
@@ -0,0 +1,47 @@
1+declare module 'frappe-charts' {
2+ export interface ChartData {
3+ labels: string[];
4+ datasets: Array<{
5+ name?: string;
6+ values: number[];
7+ chartType?: string;
8+ }>;
9+ }
10+
11+ export interface ChartOptions {
12+ data: ChartData;
13+ type: 'line' | 'bar' | 'axis-mixed' | 'pie' | 'percentage' | 'heatmap';
14+ height?: number;
15+ colors?: string[];
16+ axisOptions?: {
17+ xAxisMode?: 'tick' | 'span';
18+ xIsSeries?: boolean;
19+ yAxisMode?: 'tick' | 'span';
20+ };
21+ barOptions?: {
22+ spaceRatio?: number;
23+ stacked?: boolean;
24+ };
25+ lineOptions?: {
26+ regionFill?: number;
27+ hideDots?: number;
28+ hideLine?: number;
29+ heatline?: number;
30+ dotSize?: number;
31+ };
32+ tooltipOptions?: {
33+ formatTooltipX?: (d: string) => string;
34+ formatTooltipY?: (d: number) => string;
35+ };
36+ isNavigable?: boolean;
37+ valuesOverPoints?: boolean;
38+ maxSlices?: number;
39+ }
40+
41+ export class Chart {
42+ constructor(container: HTMLElement, options: ChartOptions);
43+ update(data: ChartData): void;
44+ destroy(): void;
45+ export(): void;
46+ }
47+}
@@ -0,0 +1,47 @@
1+declare module 'frappe-charts' {
2+ export interface ChartData {
3+ labels: string[];
4+ datasets: Array<{
5+ name?: string;
6+ values: number[];
7+ chartType?: string;
8+ }>;
9+ }
10+
11+ export interface ChartOptions {
12+ data: ChartData;
13+ type: 'line' | 'bar' | 'axis-mixed' | 'pie' | 'percentage' | 'heatmap';
14+ height?: number;
15+ colors?: string[];
16+ axisOptions?: {
17+ xAxisMode?: 'tick' | 'span';
18+ xIsSeries?: boolean;
19+ yAxisMode?: 'tick' | 'span';
20+ };
21+ barOptions?: {
22+ spaceRatio?: number;
23+ stacked?: boolean;
24+ };
25+ lineOptions?: {
26+ regionFill?: number;
27+ hideDots?: number;
28+ hideLine?: number;
29+ heatline?: number;
30+ dotSize?: number;
31+ };
32+ tooltipOptions?: {
33+ formatTooltipX?: (d: string) => string;
34+ formatTooltipY?: (d: number) => string;
35+ };
36+ isNavigable?: boolean;
37+ valuesOverPoints?: boolean;
38+ maxSlices?: number;
39+ }
40+
41+ export class Chart {
42+ constructor(container: HTMLElement, options: ChartOptions);
43+ update(data: ChartData): void;
44+ destroy(): void;
45+ export(): void;
46+ }
47+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close