svn-visualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ import {linter as defaults} from './oxc.config.ts';
2
+
3
+ // Add custom oxlint rule overrides here.
4
+ // This file is preserved on template updates.
5
+ //
6
+ // Example:
7
+ // rules: { 'no-console': 'off' }
8
+ // overrides: [{ files: ['scripts/**'], rules: { 'no-console': 'off' } }]
9
+ const rules = {
10
+ 'eslint/func-style': 'off',
11
+ 'typescript/promise-function-async': 'off',
12
+ 'unicorn/max-nested-calls': ['warn', {max: 20}],
13
+ };
14
+ const overrides = [
15
+ {
16
+ files: ['src/client/**'],
17
+ env: {browser: true},
18
+ rules: {
19
+ 'typescript/no-unsafe-type-assertion': 'off',
20
+ },
21
+ },
22
+ {
23
+ files: ['src/gather.ts'],
24
+ rules: {
25
+ 'promise/avoid-new': 'off',
26
+ },
27
+ },
28
+ ];
29
+
30
+ const config = {
31
+ ...defaults,
32
+ rules: {...defaults.rules, ...rules},
33
+ overrides: [...defaults.overrides, ...overrides],
34
+ };
35
+
36
+ export default config;
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "svn-visualizer",
3
+ "version": "0.1.0",
4
+ "description": "Generate standalone HTML activity reports from Subversion history.",
5
+ "keywords": [
6
+ "charts",
7
+ "cli",
8
+ "reporting",
9
+ "subversion",
10
+ "svn",
11
+ "visualization"
12
+ ],
13
+ "homepage": "https://github.com/doberkofler/svn-visualizer#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/doberkofler/svn-visualizer/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Dieter Oberkofler",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/doberkofler/svn-visualizer.git"
22
+ },
23
+ "bin": {
24
+ "svn-visualizer": "./dist/index.js"
25
+ },
26
+ "type": "module",
27
+ "dependencies": {
28
+ "chart.js": "4.5.1",
29
+ "commander": "15.0.0",
30
+ "debug": "4.4.3",
31
+ "fast-xml-parser": "5.11.1",
32
+ "fast-xml-validator": "1.4.2",
33
+ "zod": "4.6.5"
34
+ },
35
+ "devDependencies": {
36
+ "@commitlint/cli": "21.2.3",
37
+ "@commitlint/config-conventional": "21.2.3",
38
+ "@types/debug": "4.1.13",
39
+ "@types/node": "26.6.2",
40
+ "@vitest/coverage-v8": "5.0.1",
41
+ "conventional-changelog": "8.1.3",
42
+ "conventional-changelog-angular": "9.4.0",
43
+ "eslint-plugin-regexp": "3.3.1",
44
+ "husky": "9.1.7",
45
+ "oxfmt": "0.70.0",
46
+ "oxlint": "1.85.0",
47
+ "oxlint-tsgolint": "7.0.2002",
48
+ "release-it": "21.1.0",
49
+ "typescript": "6.0.3",
50
+ "vite": "8.3.0",
51
+ "vitest": "5.0.1"
52
+ },
53
+ "engines": {
54
+ "node": ">=22"
55
+ },
56
+ "create-template-project": {
57
+ "template": "cli"
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc --noEmit",
61
+ "lint": "oxlint",
62
+ "format": "oxfmt --write",
63
+ "format:check": "oxfmt --check",
64
+ "ci": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run build && pnpm run test",
65
+ "test": "vitest run --coverage",
66
+ "release": "release-it",
67
+ "create-changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
68
+ "svn-visualizer": "node dist/index.js",
69
+ "dev": "vite build --watch",
70
+ "build": "vite build --config vite.client.config.ts && vite build"
71
+ }
72
+ }
@@ -0,0 +1,118 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import {aggregate, resolveRange} from './aggregation.js';
3
+ import {type Commit} from './model.js';
4
+
5
+ const commits: Commit[] = [
6
+ {revision: 1, author: 'ada', date: '2025-03-01T23:30:00.000Z', message: 'a'},
7
+ {revision: 2, author: 'ada', date: '2026-02-28T00:00:00.000Z', message: 'b'},
8
+ {revision: 3, author: null, date: '2026-03-01T01:00:00.000Z', message: 'c'},
9
+ ];
10
+
11
+ describe('UTC date ranges and aggregation', () => {
12
+ it('uses deterministic relative UTC dates', () => {
13
+ expect(resolveRange(commits, {relativeDays: 30}, new Date('2026-03-01T23:59:59Z'))).toStrictEqual({from: '2026-01-31', to: '2026-03-01'});
14
+ });
15
+
16
+ it('rejects conflicts, invalid dates, and reversed dates', () => {
17
+ expect(() => resolveRange(commits, {from: '2026-01-01', relativeDays: 2})).toThrow('cannot be used');
18
+ expect(() => resolveRange(commits, {from: '2026-02-30'})).toThrow('Invalid UTC date');
19
+ expect(() => resolveRange(commits, {from: '2026-03-02', to: '2026-03-01'})).toThrow('after end date');
20
+ expect(() => resolveRange(commits, {relativeDays: 0})).toThrow('positive integer');
21
+ });
22
+
23
+ it('defaults to dataset bounds and today for empty data', () => {
24
+ expect(resolveRange(commits, {}, new Date('2030-01-01T00:00:00Z'))).toStrictEqual({from: '2025-03-01', to: '2026-03-01'});
25
+ expect(resolveRange([], {}, new Date('2026-04-05T22:00:00Z'))).toStrictEqual({from: '2026-04-05', to: '2026-04-05'});
26
+ });
27
+
28
+ it('produces UTC charts with exactly 30 days and 12 anchored months', () => {
29
+ const result = aggregate(commits, {from: '2025-03-01', to: '2026-03-01'});
30
+ expect(result.days.labels).toHaveLength(30);
31
+ expect(result.days.labels[0]).toBe('2026-01-31');
32
+ expect(result.days.labels.at(-1)).toBe('2026-03-01');
33
+ expect(result.months.labels).toHaveLength(12);
34
+ expect(result.months.labels).toStrictEqual([
35
+ '2025-04',
36
+ '2025-05',
37
+ '2025-06',
38
+ '2025-07',
39
+ '2025-08',
40
+ '2025-09',
41
+ '2025-10',
42
+ '2025-11',
43
+ '2025-12',
44
+ '2026-01',
45
+ '2026-02',
46
+ '2026-03',
47
+ ]);
48
+ expect(result.weekdays.values.reduce((sum, count) => sum + count, 0)).toBe(3);
49
+ expect(result.hours.values[23]).toBe(1);
50
+ expect(result.users).toStrictEqual({labels: ['ada', '(no author)'], values: [2, 1]});
51
+ });
52
+
53
+ it('produces a stacked commits-per-day-and-user series over the same 30 days', () => {
54
+ const result = aggregate(commits, {from: '2025-03-01', to: '2026-03-01'});
55
+ expect(result.daysByUser.labels).toStrictEqual(result.days.labels);
56
+ expect(result.daysByUser.datasets).toHaveLength(2);
57
+ const ada = result.daysByUser.datasets.find((dataset) => dataset.label === 'ada');
58
+ const noAuthor = result.daysByUser.datasets.find((dataset) => dataset.label === '(no author)');
59
+ expect(ada?.values.reduce((sum, count) => sum + count, 0)).toBe(1);
60
+ expect(noAuthor?.values.reduce((sum, count) => sum + count, 0)).toBe(1);
61
+ expect(noAuthor?.values.at(-1)).toBe(1);
62
+ });
63
+
64
+ it('stacks the top-10 buckets for commits per day and user', () => {
65
+ const many = Array.from({length: 12}, (_, index) => ({
66
+ revision: index + 1,
67
+ author: `user${String(index).padStart(2, '0')}`,
68
+ date: '2026-03-01T00:00:00.000Z',
69
+ message: 'm',
70
+ }));
71
+ const result = aggregate(many, {from: '2026-03-01', to: '2026-03-01'});
72
+ expect(result.daysByUser.datasets).toHaveLength(11);
73
+ expect(result.daysByUser.datasets.map((dataset) => dataset.label)).toStrictEqual([
74
+ 'user00',
75
+ 'user01',
76
+ 'user02',
77
+ 'user03',
78
+ 'user04',
79
+ 'user05',
80
+ 'user06',
81
+ 'user07',
82
+ 'user08',
83
+ 'user09',
84
+ '(others)',
85
+ ]);
86
+ const others = result.daysByUser.datasets.at(-1);
87
+ expect(others?.values.at(-1)).toBe(2);
88
+ });
89
+
90
+ it('caps contributors at 10 and buckets the remainder into "(others)"', () => {
91
+ const many = Array.from({length: 12}, (_, index) => ({
92
+ revision: index + 1,
93
+ author: `user${String(index).padStart(2, '0')}`,
94
+ date: '2026-03-01T00:00:00.000Z',
95
+ message: 'm',
96
+ }));
97
+ const result = aggregate(many, {from: '2026-03-01', to: '2026-03-01'});
98
+ expect(result.users.labels).toHaveLength(11);
99
+ expect(result.users.labels.slice(0, 10)).toStrictEqual(Array.from({length: 10}, (_, index) => `user${String(index).padStart(2, '0')}`));
100
+ expect(result.users.labels.at(-1)).toBe('(others)');
101
+ expect(result.users.values.at(-1)).toBe(2);
102
+ });
103
+
104
+ it('returns the 20 most recent commits within the range, newest first', () => {
105
+ const many: Commit[] = Array.from({length: 25}, (_, index) => ({
106
+ revision: index + 1,
107
+ author: 'ada',
108
+ date: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
109
+ message: `m${index}`,
110
+ }));
111
+ many.push({revision: 26, author: 'lin', date: '2025-12-31T00:00:00.000Z', message: 'outside'});
112
+ const result = aggregate(many, {from: '2026-01-01', to: '2026-01-31'});
113
+ expect(result.recent).toHaveLength(20);
114
+ expect(result.recent[0]?.revision).toBe(25);
115
+ expect(result.recent.at(-1)?.revision).toBe(6);
116
+ expect(result.recent.some((commit) => commit.revision === 26)).toBe(false);
117
+ });
118
+ });
@@ -0,0 +1,198 @@
1
+ import {z} from 'zod';
2
+ import {type Commit} from './model.js';
3
+
4
+ const DAY_MS = 86_400_000;
5
+ const dateTextSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, 'Expected YYYY-MM-DD');
6
+
7
+ export const DEFAULT_CONTRIBUTORS = 10;
8
+ export const DEFAULT_RECENT = 20;
9
+ export const OTHER_CONTRIBUTORS_LABEL = '(others)';
10
+
11
+ export type DateRange = {readonly from: string; readonly to: string};
12
+ export type Series = {readonly labels: string[]; readonly values: number[]};
13
+ export type StackedDataset = {readonly label: string; readonly values: number[]};
14
+ export type StackedSeries = {readonly labels: string[]; readonly datasets: StackedDataset[]};
15
+ export type ReportData = {
16
+ readonly range: DateRange;
17
+ readonly total: number;
18
+ readonly users: Series;
19
+ readonly weekdays: Series;
20
+ readonly hours: Series;
21
+ readonly days: Series;
22
+ readonly daysByUser: StackedSeries;
23
+ readonly months: Series;
24
+ readonly recent: Commit[];
25
+ };
26
+
27
+ function parseDateText(value: string): Date {
28
+ dateTextSchema.parse(value);
29
+ const date = new Date(`${value}T00:00:00.000Z`);
30
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
31
+ throw new Error(`Invalid UTC date: ${value}`);
32
+ }
33
+ return date;
34
+ }
35
+
36
+ function dateText(date: Date): string {
37
+ return date.toISOString().slice(0, 10);
38
+ }
39
+
40
+ function addDays(value: string, amount: number): string {
41
+ const date = parseDateText(value);
42
+ return dateText(new Date(date.getTime() + amount * DAY_MS));
43
+ }
44
+
45
+ function datasetBounds(commits: readonly Commit[], today: string): DateRange {
46
+ if (commits.length === 0) {
47
+ return {from: today, to: today};
48
+ }
49
+ const dates = commits.map((commit) => commit.date.slice(0, 10)).sort();
50
+ const [first] = dates;
51
+ const last = dates.at(-1);
52
+ if (first === undefined || last === undefined) {
53
+ return {from: today, to: today};
54
+ }
55
+ return {from: first, to: last};
56
+ }
57
+
58
+ export function resolveRange(
59
+ commits: readonly Commit[],
60
+ options: {readonly from?: string | undefined; readonly to?: string | undefined; readonly relativeDays?: number | undefined},
61
+ now = new Date(),
62
+ ): DateRange {
63
+ const today = dateText(now);
64
+ parseDateText(today);
65
+ if (options.relativeDays !== undefined) {
66
+ if (options.from !== undefined || options.to !== undefined) {
67
+ throw new Error('--from/--to cannot be used with --relative-days');
68
+ }
69
+ if (!Number.isInteger(options.relativeDays) || options.relativeDays <= 0) {
70
+ throw new Error('--relative-days must be a positive integer');
71
+ }
72
+ return {from: addDays(today, 1 - options.relativeDays), to: today};
73
+ }
74
+ const bounds = datasetBounds(commits, today);
75
+ const from = options.from ?? bounds.from;
76
+ const to = options.to ?? bounds.to;
77
+ parseDateText(from);
78
+ parseDateText(to);
79
+ if (from > to) {
80
+ throw new Error(`Report start date ${from} is after end date ${to}`);
81
+ }
82
+ return {from, to};
83
+ }
84
+
85
+ function countSeries(labels: readonly string[], keys: readonly string[]): Series {
86
+ const counts = new Map(labels.map((label) => [label, 0]));
87
+ for (const key of keys) {
88
+ if (counts.has(key)) {
89
+ counts.set(key, (counts.get(key) ?? 0) + 1);
90
+ }
91
+ }
92
+ return {labels: [...labels], values: labels.map((label) => counts.get(label) ?? 0)};
93
+ }
94
+
95
+ function countStackedSeries(
96
+ labels: readonly string[],
97
+ buckets: readonly string[],
98
+ entries: readonly {readonly key: string; readonly bucket: string}[],
99
+ ): StackedSeries {
100
+ const bucketValues = new Map(buckets.map((bucket) => [bucket, Array.from({length: labels.length}, () => 0)]));
101
+ const indexByLabel = new Map(labels.map((label, index) => [label, index]));
102
+ for (const {key, bucket} of entries) {
103
+ const values = bucketValues.get(bucket);
104
+ const index = indexByLabel.get(key);
105
+ if (values === undefined || index === undefined) {
106
+ continue;
107
+ }
108
+ values[index] = (values[index] ?? 0) + 1;
109
+ }
110
+ return {labels: [...labels], datasets: buckets.map((bucket) => ({label: bucket, values: [...(bucketValues.get(bucket) ?? [])]}))};
111
+ }
112
+
113
+ function monthLabels(end: string): string[] {
114
+ const endDate = parseDateText(end);
115
+ const result: string[] = [];
116
+ for (let offset = 11; offset >= 0; offset--) {
117
+ result.push(dateText(new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - offset, 1))).slice(0, 7));
118
+ }
119
+ return result;
120
+ }
121
+
122
+ export function aggregate(commits: readonly Commit[], range: DateRange): ReportData {
123
+ parseDateText(range.from);
124
+ parseDateText(range.to);
125
+ if (range.from > range.to) {
126
+ throw new Error('Report date range is reversed');
127
+ }
128
+ const selected = commits.filter((commit) => {
129
+ const date = commit.date.slice(0, 10);
130
+ return date >= range.from && date <= range.to;
131
+ });
132
+
133
+ const userCounts = new Map<string, number>();
134
+ for (const commit of selected) {
135
+ const author = commit.author === null || commit.author === '' ? '(no author)' : commit.author;
136
+ userCounts.set(author, (userCounts.get(author) ?? 0) + 1);
137
+ }
138
+ const ranked = [...userCounts.entries()].sort(
139
+ ([leftName, leftCount], [rightName, rightCount]) => rightCount - leftCount || leftName.localeCompare(rightName),
140
+ );
141
+ let userLabels: string[];
142
+ let userValues: number[];
143
+ const bucketOf = new Map<string, string>();
144
+ if (ranked.length > DEFAULT_CONTRIBUTORS) {
145
+ const top = ranked.slice(0, DEFAULT_CONTRIBUTORS);
146
+ const others = ranked.slice(DEFAULT_CONTRIBUTORS).reduce((sum, [, count]) => sum + count, 0);
147
+ userLabels = [...top.map(([name]) => name), OTHER_CONTRIBUTORS_LABEL];
148
+ userValues = [...top.map(([, count]) => count), others];
149
+ for (const [name] of top) {
150
+ bucketOf.set(name, name);
151
+ }
152
+ for (const [name] of ranked.slice(DEFAULT_CONTRIBUTORS)) {
153
+ bucketOf.set(name, OTHER_CONTRIBUTORS_LABEL);
154
+ }
155
+ } else {
156
+ userLabels = ranked.map(([name]) => name);
157
+ userValues = ranked.map(([, count]) => count);
158
+ for (const [name] of ranked) {
159
+ bucketOf.set(name, name);
160
+ }
161
+ }
162
+ const weekdayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
163
+ const hourLabels = Array.from({length: 24}, (_, hour) => hour.toString().padStart(2, '0'));
164
+ const dayLabels = Array.from({length: 30}, (_, index) => addDays(range.to, index - 29));
165
+ const months = monthLabels(range.to);
166
+ const recent = [...selected].sort((left, right) => right.revision - left.revision).slice(0, DEFAULT_RECENT);
167
+ const daysByUser = countStackedSeries(
168
+ dayLabels,
169
+ userLabels,
170
+ selected.map((commit) => {
171
+ const author = commit.author === null || commit.author === '' ? '(no author)' : commit.author;
172
+ return {key: commit.date.slice(0, 10), bucket: bucketOf.get(author) ?? OTHER_CONTRIBUTORS_LABEL};
173
+ }),
174
+ );
175
+ return {
176
+ range,
177
+ total: selected.length,
178
+ users: {labels: userLabels, values: userValues},
179
+ weekdays: countSeries(
180
+ weekdayLabels,
181
+ selected.map((commit) => weekdayLabels[(new Date(commit.date).getUTCDay() + 6) % 7] ?? 'Monday'),
182
+ ),
183
+ hours: countSeries(
184
+ hourLabels,
185
+ selected.map((commit) => new Date(commit.date).getUTCHours().toString().padStart(2, '0')),
186
+ ),
187
+ days: countSeries(
188
+ dayLabels,
189
+ selected.map((commit) => commit.date.slice(0, 10)),
190
+ ),
191
+ daysByUser,
192
+ months: countSeries(
193
+ months,
194
+ selected.map((commit) => commit.date.slice(0, 7)),
195
+ ),
196
+ recent,
197
+ };
198
+ }
@@ -0,0 +1,10 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {describe, expect, it} from 'vitest';
3
+ import {createProgram} from './cli.js';
4
+
5
+ describe('CLI', () => {
6
+ it('reports the package version', () => {
7
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {version: string};
8
+ expect(createProgram().version()).toBe(packageJson.version);
9
+ });
10
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,61 @@
1
+ import {Command, Option} from 'commander';
2
+ import {gather, type GatherOptions} from './gather.js';
3
+ import {generate, type GenerateOptions} from './report.js';
4
+
5
+ declare const APP_VERSION: string;
6
+
7
+ type CommonGatherOptions = Omit<GatherOptions, 'url'> & {readonly url: string};
8
+
9
+ function positiveInteger(value: string): number {
10
+ const parsed = Number(value);
11
+ if (!Number.isInteger(parsed) || parsed <= 0) {
12
+ throw new Error('Expected a positive integer');
13
+ }
14
+ return parsed;
15
+ }
16
+
17
+ function gatherOptions(command: Command): Command {
18
+ return command
19
+ .requiredOption('--url <url>', 'SVN repository URL')
20
+ .option('--username <username>', 'SVN username')
21
+ .option('--password-env <name>', 'environment variable containing the SVN password', 'SVN_PASSWORD')
22
+ .option('--data-file <path>', 'JSON state file', 'svn-data.json')
23
+ .option('--svn-binary <path>', 'SVN executable', 'svn');
24
+ }
25
+
26
+ function generateOptions(command: Command, includeDataFile = true): Command {
27
+ if (includeDataFile) {
28
+ command.option('--data-file <path>', 'JSON state file', 'svn-data.json');
29
+ }
30
+ return command
31
+ .option('--output-dir <path>', 'report output directory', 'output')
32
+ .option('--from <date>', 'first UTC date (YYYY-MM-DD)')
33
+ .option('--to <date>', 'last UTC date (YYYY-MM-DD)')
34
+ .addOption(new Option('--relative-days <days>', 'rolling number of UTC days').argParser(positiveInteger))
35
+ .option('--title <title>', 'report title', 'Subversion activity');
36
+ }
37
+
38
+ export function createProgram(): Command {
39
+ const program = new Command().name('svn-visualizer').description('Generate standalone HTML activity reports from Subversion history').version(APP_VERSION);
40
+
41
+ gatherOptions(program.command('gather').description('Incrementally gather SVN history')).action(async (options: CommonGatherOptions) => {
42
+ const result = await gather(options);
43
+ console.log(result.added === 0 ? 'No new commits.' : `Gathered ${String(result.added)} new commit(s).`);
44
+ });
45
+
46
+ generateOptions(program.command('generate').description('Generate a standalone HTML report')).action(async (options: GenerateOptions) => {
47
+ const output = await generate(options);
48
+ console.log(`Generated ${output}`);
49
+ });
50
+
51
+ generateOptions(gatherOptions(program.command('report').description('Gather history and generate a report')), false).action(
52
+ async (options: CommonGatherOptions & GenerateOptions) => {
53
+ const result = await gather(options);
54
+ console.log(result.added === 0 ? 'No new commits.' : `Gathered ${String(result.added)} new commit(s).`);
55
+ const output = await generate(options);
56
+ console.log(`Generated ${output}`);
57
+ },
58
+ );
59
+
60
+ return program;
61
+ }
@@ -0,0 +1,117 @@
1
+ import Chart from 'chart.js/auto';
2
+
3
+ type Series = {readonly labels: string[]; readonly values: number[]};
4
+ type StackedSeries = {readonly labels: string[]; readonly datasets: readonly {readonly label: string; readonly values: number[]}[]};
5
+ type RecentCommit = {readonly revision: number; readonly author: string | null; readonly date: string; readonly message: string};
6
+ type BrowserData = {
7
+ readonly days: Series;
8
+ readonly daysByUser: StackedSeries;
9
+ readonly months: Series;
10
+ readonly users: Series;
11
+ readonly weekdays: Series;
12
+ readonly hours: Series;
13
+ readonly recent: RecentCommit[];
14
+ };
15
+
16
+ const element = document.querySelector('#report-data');
17
+ const reportText = element?.textContent;
18
+ if (reportText === undefined) {
19
+ throw new Error('Report data is missing');
20
+ }
21
+ const data = JSON.parse(reportText) as BrowserData;
22
+ const charts: Chart[] = [];
23
+ const accent = '#f3b33d';
24
+ const cool = '#6dc8bf';
25
+ const common = {
26
+ responsive: true,
27
+ maintainAspectRatio: false,
28
+ plugins: {legend: {display: false}},
29
+ scales: {
30
+ x: {ticks: {color: '#aaa69d'}, grid: {color: '#34413e'}},
31
+ y: {beginAtZero: true, ticks: {color: '#aaa69d', precision: 0}, grid: {color: '#34413e'}},
32
+ },
33
+ } as const;
34
+
35
+ function chart(id: string, series: Series, type: 'bar' | 'line', color: string): void {
36
+ const canvas = document.querySelector<HTMLCanvasElement>(`#${id}`);
37
+ if (canvas === null) {
38
+ return;
39
+ }
40
+ charts.push(
41
+ new Chart(canvas, {
42
+ type,
43
+ data: {
44
+ labels: series.labels,
45
+ datasets: [{data: series.values, borderColor: color, backgroundColor: `${color}99`, fill: type === 'line', tension: 0.25}],
46
+ },
47
+ options: common,
48
+ }),
49
+ );
50
+ }
51
+
52
+ const palette = [accent, cool, '#f77f00', '#90be6d', '#9b5de5', '#f94144', '#577590', '#43aa8b', '#f9844a', '#277da1'];
53
+
54
+ function stackedChart(id: string, series: StackedSeries): void {
55
+ const canvas = document.querySelector<HTMLCanvasElement>(`#${id}`);
56
+ if (canvas === null) {
57
+ return;
58
+ }
59
+ charts.push(
60
+ new Chart(canvas, {
61
+ type: 'bar',
62
+ data: {
63
+ labels: series.labels,
64
+ datasets: series.datasets.map((dataset, index) => ({
65
+ label: dataset.label,
66
+ data: dataset.values,
67
+ backgroundColor: `${palette[index % palette.length] ?? accent}99`,
68
+ borderColor: palette[index % palette.length] ?? accent,
69
+ })),
70
+ },
71
+ options: {
72
+ ...common,
73
+ plugins: {legend: {display: true, position: 'bottom', labels: {color: '#aaa69d'}}},
74
+ scales: {
75
+ x: {...common.scales.x, stacked: true},
76
+ y: {...common.scales.y, stacked: true},
77
+ },
78
+ },
79
+ }),
80
+ );
81
+ }
82
+
83
+ chart('days', data.days, 'line', accent);
84
+ stackedChart('days-by-user', data.daysByUser);
85
+ chart('months', data.months, 'bar', cool);
86
+ chart('users', data.users, 'bar', accent);
87
+ chart('weekdays', data.weekdays, 'bar', cool);
88
+ chart('hours', data.hours, 'bar', accent);
89
+
90
+ function formatUtc(value: string): string {
91
+ return new Date(value).toISOString().slice(0, 16).replace('T', ' ');
92
+ }
93
+
94
+ function renderCommits(commits: readonly RecentCommit[]): void {
95
+ const tbody = document.querySelector<HTMLTableSectionElement>('#commits');
96
+ if (tbody === null) {
97
+ return;
98
+ }
99
+ for (const commit of commits) {
100
+ const row = document.createElement('tr');
101
+ const revision = document.createElement('td');
102
+ revision.textContent = String(commit.revision);
103
+ revision.className = 'rev';
104
+ const author = document.createElement('td');
105
+ author.textContent = commit.author ?? '(no author)';
106
+ const date = document.createElement('td');
107
+ date.textContent = formatUtc(commit.date);
108
+ date.className = 'date';
109
+ const message = document.createElement('td');
110
+ message.textContent = commit.message;
111
+ message.className = 'msg';
112
+ row.append(revision, author, date, message);
113
+ tbody.append(row);
114
+ }
115
+ }
116
+
117
+ renderCommits(data.recent);