Skip to content

Commit 5293c61

Browse files
tbjersclaude
andcommitted
feat: add category selection to status badges and time-windowed trend charts
- Wire category filtering through the badge endpoint and modal, per the badge-category-selection plan. - Add a relative time-range selector (15m-30d) to project detail trend charts, backed by a server-side window+anchor query so short-history or staggered-cadence categories render as full-width lines instead of a lone dot or partial-width segment. - Switch the project list's multi-category overlay to a fixed, aligned 30d window with gradient-filled lines in distinct colors per category. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c4ac821 commit 5293c61

20 files changed

Lines changed: 1140 additions & 94 deletions

dashboard/src/lib/api.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,35 @@
1-
import type { ProjectRow, TrendResponse, GroupedTrendResponse } from './types';
1+
import type { ProjectRow, GroupedTrendResponse, RangeKey } from './types';
22

33
export async function fetchProjects(fetchFn: typeof fetch = fetch): Promise<ProjectRow[]> {
44
const res = await fetchFn('/api/projects', { redirect: 'manual' });
55
if (!res.ok) throw new Error(`Failed to fetch projects: HTTP ${res.status}`);
66
return res.json() as Promise<ProjectRow[]>;
77
}
88

9-
export async function fetchTrend(
10-
owner: string,
11-
repo: string,
12-
metric: string,
13-
branch: string,
14-
limit: number,
15-
fetchFn: typeof fetch = fetch,
16-
): Promise<TrendResponse> {
17-
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
18-
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics?${params}`, {
19-
redirect: 'manual',
20-
});
21-
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
22-
return res.json() as Promise<TrendResponse>;
9+
export interface TrendOptions {
10+
/** Row-count cap for the legacy (unwindowed) fetch — ignored when `range` is set. */
11+
limit?: number;
12+
/** Relative time window; when set, the backend returns an edge-anchored, windowed series. */
13+
range?: RangeKey;
14+
/** Align all categories to a shared right edge, carrying stale series forward. Requires `range`. */
15+
align?: boolean;
2316
}
2417

2518
export async function fetchTrendByCategory(
2619
owner: string,
2720
repo: string,
2821
metric: string,
2922
branch: string,
30-
limit: number,
23+
options: TrendOptions,
3124
fetchFn: typeof fetch = fetch,
3225
): Promise<GroupedTrendResponse> {
33-
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
26+
const params = new URLSearchParams({ metric, branch });
27+
if (options.range) {
28+
params.set('range', options.range);
29+
if (options.align) params.set('align', 'true');
30+
} else {
31+
params.set('limit', String(options.limit ?? 100));
32+
}
3433
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics/categories?${params}`, {
3534
redirect: 'manual',
3635
});

dashboard/src/lib/chartFill.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type uPlot from 'uplot';
2+
3+
export function hexAlpha(hex: string, alpha: number): string {
4+
const r = parseInt(hex.slice(1, 3), 16);
5+
const g = parseInt(hex.slice(3, 5), 16);
6+
const b = parseInt(hex.slice(5, 7), 16);
7+
return `rgba(${r},${g},${b},${alpha})`;
8+
}
9+
10+
/** Top-to-bottom fading fill, used by every uPlot line series in the dashboard. */
11+
export function gradientFill(color: string, topAlpha = 0.28, bottomAlpha = 0.02) {
12+
return (u: uPlot) => {
13+
const grad = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
14+
grad.addColorStop(0, hexAlpha(color, topAlpha));
15+
grad.addColorStop(1, hexAlpha(color, bottomAlpha));
16+
return grad;
17+
};
18+
}

dashboard/src/lib/components/BadgeModal.svelte

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
<script lang="ts">
22
import { untrack } from 'svelte';
33
import { invalidateAll } from '$app/navigation';
4+
import { fetchTrendByCategory } from '$lib/api';
45
56
let {
67
owner,
78
repo,
89
projectId,
910
badgeEnabled,
11+
defaultBranch,
1012
onclose,
1113
}: {
1214
owner: string;
1315
repo: string;
1416
projectId: number;
1517
badgeEnabled: number;
18+
defaultBranch: string;
1619
onclose: () => void;
1720
} = $props();
1821
@@ -26,16 +29,42 @@
2629
];
2730
2831
let selectedMetric = $state('coverage');
32+
let categories = $state<string[]>(['default']);
33+
let selectedCategory = $state('default');
2934
let localBadgeEnabled = $state(untrack(() => badgeEnabled));
3035
let toggling = $state(false);
3136
37+
$effect(() => {
38+
const metric = selectedMetric;
39+
(async () => {
40+
let next = ['default'];
41+
try {
42+
const result = await fetchTrendByCategory(owner, repo, metric, defaultBranch, { limit: 1 });
43+
if (result.categories.length > 0) {
44+
next = result.categories.map((c) => c.category);
45+
}
46+
} catch {
47+
// fall back to ['default']
48+
}
49+
categories = next;
50+
if (!next.includes(selectedCategory)) {
51+
selectedCategory = next[0] ?? 'default';
52+
}
53+
})();
54+
});
55+
3256
const badgeEndpointUrl = $derived(
33-
`${window.location.origin}/api/badge/${owner}/${repo}/${selectedMetric}.json`,
57+
`${window.location.origin}/api/badge/${owner}/${repo}/${selectedMetric}.json${
58+
selectedCategory !== 'default' ? `?category=${encodeURIComponent(selectedCategory)}` : ''
59+
}`,
3460
);
3561
const shieldsUrl = $derived(
3662
`https://img.shields.io/endpoint?url=${encodeURIComponent(badgeEndpointUrl)}`,
3763
);
38-
const markdownSnippet = $derived(`![${selectedMetric} badge](${shieldsUrl})`);
64+
const badgeAltText = $derived(
65+
`${selectedMetric}${selectedCategory !== 'default' ? ` (${selectedCategory})` : ''} badge`,
66+
);
67+
const markdownSnippet = $derived(`![${badgeAltText}](${shieldsUrl})`);
3968
const rstSnippet = $derived(`.. image:: ${shieldsUrl}`);
4069
4170
let copiedField: string | null = $state(null);
@@ -155,11 +184,18 @@
155184
<option value={m.value}>{m.label}</option>
156185
{/each}
157186
</select>
187+
188+
<label for="badge-category-select" class="metric-label">Category</label>
189+
<select id="badge-category-select" class="metric-select" bind:value={selectedCategory}>
190+
{#each categories as cat (cat)}
191+
<option value={cat}>{cat}</option>
192+
{/each}
193+
</select>
158194
</div>
159195

160196
<div class="badge-preview">
161197
{#if localBadgeEnabled}
162-
<img src={shieldsUrl} alt="{selectedMetric} status badge" />
198+
<img src={shieldsUrl} alt="{badgeAltText} status badge" />
163199
{:else}
164200
<span class="badge-placeholder">badge preview unavailable</span>
165201
{/if}
@@ -358,6 +394,7 @@
358394
display: flex;
359395
align-items: center;
360396
gap: 10px;
397+
flex-wrap: wrap;
361398
}
362399
363400
.metric-label {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<script lang="ts">
2+
import uPlot from 'uplot';
3+
import 'uplot/dist/uPlot.min.css';
4+
import { gradientFill } from '../chartFill';
5+
6+
let {
7+
series,
8+
}: {
9+
series: { category: string; timestamps: number[]; values: number[]; color: string }[];
10+
} = $props();
11+
12+
let container: HTMLDivElement;
13+
let chart: uPlot | null = null;
14+
15+
function buildChart() {
16+
chart?.destroy();
17+
chart = null;
18+
if (!container || series.length === 0) return;
19+
20+
const tables = series.map((s) => [s.timestamps, s.values]) as uPlot.AlignedData[];
21+
const joined = series.length > 1 ? uPlot.join(tables) : tables[0];
22+
if ((joined[0] as number[]).length < 2) return;
23+
24+
chart = new uPlot(
25+
{
26+
width: container.clientWidth,
27+
height: 44,
28+
padding: [4, 0, 4, 0],
29+
axes: [{ show: false, size: 0 }, { show: false, size: 0 }],
30+
scales: { x: { time: true } },
31+
legend: { show: false },
32+
cursor: { show: false },
33+
select: { show: false },
34+
series: [
35+
{},
36+
...series.map((s) => ({
37+
stroke: s.color,
38+
fill: gradientFill(s.color, 0.16, 0.01),
39+
width: 1.5,
40+
points: { show: false },
41+
})),
42+
],
43+
},
44+
joined,
45+
container,
46+
);
47+
}
48+
49+
$effect(() => {
50+
void series;
51+
buildChart();
52+
53+
if (!container) return;
54+
const observer = new ResizeObserver(() => {
55+
if (chart && container) {
56+
chart.setSize({ width: container.clientWidth, height: 44 });
57+
}
58+
});
59+
observer.observe(container);
60+
61+
return () => {
62+
observer.disconnect();
63+
chart?.destroy();
64+
chart = null;
65+
};
66+
});
67+
</script>
68+
69+
<div bind:this={container} class="multi-sparkline"></div>
70+
71+
<style>
72+
.multi-sparkline {
73+
width: 100%;
74+
}
75+
.multi-sparkline :global(.u-wrap) {
76+
overflow: visible;
77+
}
78+
.multi-sparkline :global(.u-title),
79+
.multi-sparkline :global(.u-legend) {
80+
display: none;
81+
}
82+
</style>

dashboard/src/lib/components/SparkLine.svelte

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import uPlot from 'uplot';
33
import 'uplot/dist/uPlot.min.css';
4+
import { gradientFill } from '../chartFill';
45
56
let {
67
timestamps,
@@ -11,13 +12,6 @@
1112
let container: HTMLDivElement;
1213
let chart: uPlot | null = null;
1314
14-
function hexAlpha(hex: string, alpha: number): string {
15-
const r = parseInt(hex.slice(1, 3), 16);
16-
const g = parseInt(hex.slice(3, 5), 16);
17-
const b = parseInt(hex.slice(5, 7), 16);
18-
return `rgba(${r},${g},${b},${alpha})`;
19-
}
20-
2115
function buildChart(c: string) {
2216
chart?.destroy();
2317
if (timestamps.length < 2 || !container) { chart = null; return; }
@@ -36,7 +30,7 @@
3630
{},
3731
{
3832
stroke: c,
39-
fill: hexAlpha(c, 0.2),
33+
fill: gradientFill(c),
4034
width: 1.5,
4135
points: { show: false },
4236
},

dashboard/src/lib/components/TrendChart.svelte

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import uPlot from 'uplot';
33
import 'uplot/dist/uPlot.min.css';
44
import type { MetricPoint } from '../types';
5+
import { hexAlpha, gradientFill } from '../chartFill';
56
67
let {
78
data,
@@ -24,22 +25,6 @@
2425
let container: HTMLDivElement;
2526
let chart: uPlot | null = null;
2627
27-
function hexAlpha(hex: string, alpha: number): string {
28-
const r = parseInt(hex.slice(1, 3), 16);
29-
const g = parseInt(hex.slice(3, 5), 16);
30-
const b = parseInt(hex.slice(5, 7), 16);
31-
return `rgba(${r},${g},${b},${alpha})`;
32-
}
33-
34-
function gradientFill(c: string) {
35-
return (u: uPlot) => {
36-
const grad = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
37-
grad.addColorStop(0, hexAlpha(c, 0.28));
38-
grad.addColorStop(1, hexAlpha(c, 0.02));
39-
return grad;
40-
};
41-
}
42-
4328
// Draw a dot + vertical dashed guide at the last data point
4429
function lastPointPlugin(c: string, bc: string) {
4530
return {

dashboard/src/lib/types.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,8 @@ export interface MetricPoint {
1515
value: number;
1616
unit: string;
1717
recorded_at: string;
18-
}
19-
20-
export interface TrendResponse {
21-
project: string;
22-
branch: string;
23-
metric: string;
24-
data: MetricPoint[];
18+
/** True for a point synthesized to anchor/carry a line to a window boundary — not a real run. */
19+
synthetic?: boolean;
2520
}
2621

2722
export interface CategoryTrend {
@@ -39,3 +34,18 @@ export interface GroupedTrendResponse {
3934
export type MetricName = 'coverage' | 'complexity' | 'duplication';
4035

4136
export const METRICS: MetricName[] = ['coverage', 'complexity', 'duplication'];
37+
38+
export type RangeKey = '15m' | '1h' | '12h' | '1d' | '7d' | '30d';
39+
40+
export const RANGES: { key: RangeKey; label: string }[] = [
41+
{ key: '15m', label: '15m' },
42+
{ key: '1h', label: '1h' },
43+
{ key: '12h', label: '12h' },
44+
{ key: '1d', label: '1d' },
45+
{ key: '7d', label: '7d' },
46+
{ key: '30d', label: '30d' },
47+
];
48+
49+
export function isRangeKey(value: string): value is RangeKey {
50+
return RANGES.some((r) => r.key === value);
51+
}

0 commit comments

Comments
 (0)