Skip to content

Commit 6e5d127

Browse files
committed
feat: intent registry — sparkline charts, skill history, changelog, URL-tracked state
- SkillSparkline: bar chart with colored added/removed/modified bars, gray unchanged bars, Plot.tip hover tooltips with color swatches - PlotContainer: responsive Observable Plot wrapper with overflow:visible fix and loading placeholders - Package detail: Skill/History tabs (URL-tracked), version selector synced to URL, compact header layout with inline sparkline - Changelog (History tab): collapsible timeline with grouped unchanged versions, multi-expand tracked in URL, latest expanded by default - Skill detail: Skill/History tabs replacing collapsible, URL-tracked tab state - Skills list: multi-expand tracked in URL, added/modified badges from changelog diff - Sidebar: All Skills link above skill list - Registry index: sparkline placeholders while loading, compact card layout with inline sparklines - Search schema uses v.fallback for graceful handling of invalid URL params - Update @sentry/tanstackstart-react to fix upstream type errors
1 parent 510a8f0 commit 6e5d127

File tree

14 files changed

+2144
-301
lines changed

14 files changed

+2144
-301
lines changed

pnpm-lock.yaml

Lines changed: 159 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as React from 'react'
2+
import * as Plot from '@observablehq/plot'
3+
4+
interface PlotContainerProps {
5+
/** Build Plot options given the measured container width. */
6+
options: (width: number) => Parameters<typeof Plot.plot>[0]
7+
/** Fixed height in pixels. Passed through to the wrapper div. */
8+
height: number
9+
className?: string
10+
style?: React.CSSProperties
11+
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
12+
}
13+
14+
export function PlotContainer({
15+
options,
16+
height,
17+
className,
18+
style,
19+
onClick,
20+
}: PlotContainerProps) {
21+
const containerRef = React.useRef<HTMLDivElement>(null)
22+
const [ready, setReady] = React.useState(false)
23+
24+
React.useEffect(() => {
25+
const container = containerRef.current
26+
if (!container) return
27+
28+
let currentPlot: ReturnType<typeof Plot.plot> | null = null
29+
30+
const render = () => {
31+
if (currentPlot) {
32+
currentPlot.remove()
33+
currentPlot = null
34+
}
35+
36+
const width = container.clientWidth
37+
if (width === 0) return
38+
39+
currentPlot = Plot.plot({ ...options(width), width, height })
40+
currentPlot.style.overflow = 'visible'
41+
container.appendChild(currentPlot)
42+
setReady(true)
43+
}
44+
45+
render()
46+
47+
const observer = new ResizeObserver(render)
48+
observer.observe(container)
49+
50+
return () => {
51+
observer.disconnect()
52+
if (currentPlot) currentPlot.remove()
53+
}
54+
}, [options, height])
55+
56+
const handleKeyDown = React.useCallback(
57+
(e: React.KeyboardEvent<HTMLDivElement>) => {
58+
if (!onClick || (e.key !== 'Enter' && e.key !== ' ')) return
59+
e.preventDefault()
60+
// Synthesize a click at the container center for keyboard users
61+
const el = e.currentTarget
62+
const rect = el.getBoundingClientRect()
63+
const synth = new MouseEvent('click', {
64+
bubbles: true,
65+
clientX: rect.left + rect.width / 2,
66+
clientY: rect.top + rect.height / 2,
67+
})
68+
el.dispatchEvent(synth)
69+
},
70+
[onClick],
71+
)
72+
73+
return (
74+
<div
75+
ref={containerRef}
76+
className={`${!ready ? 'animate-pulse rounded bg-gray-100 dark:bg-gray-800/40' : ''} ${className ?? ''}`}
77+
style={{ width: '100%', height, ...style }}
78+
onClick={onClick}
79+
onKeyDown={onClick ? handleKeyDown : undefined}
80+
role={onClick ? 'button' : undefined}
81+
tabIndex={onClick ? 0 : undefined}
82+
aria-label={onClick ? 'Interactive chart' : undefined}
83+
/>
84+
)
85+
}

src/components/charts/TimeSeriesChart.tsx

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react'
22
import * as Plot from '@observablehq/plot'
33
import { type BinType, binTimeSeriesData } from '~/utils/chart'
4+
import { PlotContainer } from '~/components/charts/PlotContainer'
45

56
export type ChartVariant = 'area' | 'bar' | 'cumulative'
67

@@ -21,47 +22,26 @@ export function TimeSeriesChart({
2122
height = 200,
2223
yLabel,
2324
}: TimeSeriesChartProps) {
24-
const containerRef = React.useRef<HTMLDivElement>(null)
25-
26-
React.useEffect(() => {
27-
if (!containerRef.current || data.length === 0) return
28-
29-
const container = containerRef.current
30-
31-
const renderChart = () => {
32-
if (!container) return
33-
container.innerHTML = ''
34-
25+
const options = React.useCallback(
26+
(_width: number) => {
3527
const binnedData = binTimeSeriesData(data, binType)
36-
if (binnedData.length === 0) return
28+
if (binnedData.length === 0) return { marks: [] }
3729

3830
const marks = getMarksForVariant(variant, binnedData, color, binType)
3931

40-
const plot = Plot.plot({
41-
width: container.clientWidth,
42-
height,
32+
return {
4333
marginLeft: 60,
4434
marginRight: 20,
4535
marginTop: 20,
4636
marginBottom: 40,
47-
x: { label: 'Date', type: 'utc', grid: true },
37+
x: { label: 'Date', type: 'utc' as const, grid: true },
4838
y: { label: yLabel ?? getDefaultYLabel(variant), grid: true },
4939
marks,
5040
style: { background: 'transparent', fontSize: '12px' },
51-
})
52-
53-
container.appendChild(plot)
54-
}
55-
56-
renderChart()
57-
const resizeObserver = new ResizeObserver(() => renderChart())
58-
resizeObserver.observe(container)
59-
60-
return () => {
61-
resizeObserver.disconnect()
62-
container.innerHTML = ''
63-
}
64-
}, [data, binType, variant, color, height, yLabel])
41+
}
42+
},
43+
[data, binType, variant, color, yLabel],
44+
)
6545

6646
if (data.length === 0) {
6747
return (
@@ -71,7 +51,7 @@ export function TimeSeriesChart({
7151
)
7252
}
7353

74-
return <div ref={containerRef} className="w-full" />
54+
return <PlotContainer options={options} height={height} />
7555
}
7656

7757
function getDefaultYLabel(variant: ChartVariant): string {
@@ -89,15 +69,6 @@ function getMarksForVariant(
8969
color: string,
9070
binType: BinType,
9171
): Plot.Markish[] {
92-
const _tipFormat = {
93-
x: (d: Date) =>
94-
d.toLocaleDateString('en-US', {
95-
month: 'long',
96-
day: 'numeric',
97-
year: 'numeric',
98-
}),
99-
}
100-
10172
switch (variant) {
10273
case 'bar':
10374
return [
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as React from 'react'
2+
import * as Plot from '@observablehq/plot'
3+
import { PlotContainer } from '~/components/charts/PlotContainer'
4+
import type { SkillHistoryEntry } from '~/utils/intent.functions'
5+
6+
export function SkillSparklinePlaceholder({
7+
height = 40,
8+
}: {
9+
height?: number
10+
}) {
11+
return (
12+
<div
13+
className="animate-pulse rounded bg-gray-100 dark:bg-gray-800/40"
14+
style={{ width: '100%', height }}
15+
/>
16+
)
17+
}
18+
19+
interface SkillSparklineProps {
20+
history: Array<SkillHistoryEntry>
21+
height?: number
22+
maxSlots?: number
23+
onVersionClick?: (entry: SkillHistoryEntry, index: number) => void
24+
}
25+
26+
export function SkillSparkline({
27+
history,
28+
height = 40,
29+
maxSlots,
30+
onVersionClick,
31+
}: SkillSparklineProps) {
32+
const n = history.length
33+
const slots = Math.max(maxSlots ?? n, n)
34+
const offset = slots - n
35+
36+
const options = React.useCallback(
37+
(width: number) => {
38+
const pxPerSlot = width / slots
39+
const barPx = Math.min(10, pxPerSlot * 0.6)
40+
const barW = (barPx / 2) * (slots / width)
41+
42+
const rectData: Array<{
43+
x1: number
44+
x2: number
45+
y1: number
46+
y2: number
47+
type: string
48+
}> = []
49+
50+
for (let i = 0; i < n; i++) {
51+
const entry = history[i]
52+
const x = i + offset
53+
const hasChanges =
54+
entry.added > 0 || entry.modified > 0 || entry.removed > 0
55+
56+
if (hasChanges) {
57+
let y0 = 0
58+
if (entry.added > 0) {
59+
rectData.push({
60+
x1: x - barW,
61+
x2: x + barW,
62+
y1: y0,
63+
y2: y0 + entry.added,
64+
type: 'added',
65+
})
66+
y0 += entry.added
67+
}
68+
if (entry.modified > 0) {
69+
rectData.push({
70+
x1: x - barW,
71+
x2: x + barW,
72+
y1: y0,
73+
y2: y0 + entry.modified,
74+
type: 'modified',
75+
})
76+
y0 += entry.modified
77+
}
78+
if (entry.removed > 0) {
79+
rectData.push({
80+
x1: x - barW,
81+
x2: x + barW,
82+
y1: y0,
83+
y2: y0 + entry.removed,
84+
type: 'removed',
85+
})
86+
}
87+
} else {
88+
// No changes — gray bar showing total
89+
rectData.push({
90+
x1: x - barW,
91+
x2: x + barW,
92+
y1: 0,
93+
y2: entry.total,
94+
type: 'unchanged',
95+
})
96+
}
97+
}
98+
99+
const yMax = Math.max(...history.map((h) => h.total), 1)
100+
101+
const tipData = history.map((d, i) => {
102+
const status =
103+
d.added > 0
104+
? 'added'
105+
: d.modified > 0
106+
? 'modified'
107+
: d.removed > 0
108+
? 'removed'
109+
: 'unchanged'
110+
return { ...d, _x: i + offset, _status: status }
111+
})
112+
113+
return {
114+
height,
115+
marginLeft: 0,
116+
marginRight: 0,
117+
marginTop: 2,
118+
marginBottom: 2,
119+
x: { axis: null, domain: [-0.5, slots - 0.5] },
120+
y: { axis: null, domain: [0, yMax] },
121+
marks: [
122+
Plot.rect(rectData, {
123+
x1: 'x1',
124+
x2: 'x2',
125+
y1: 'y1',
126+
y2: 'y2',
127+
fill: 'type',
128+
}),
129+
Plot.tip(
130+
tipData,
131+
Plot.pointer({
132+
x: '_x',
133+
y: 'total',
134+
fill: '_status',
135+
channels: {
136+
Version: (d) => `v${d.version}`,
137+
Skills: (d) => d.total,
138+
Added: (d) => (d.added > 0 ? `+${d.added}` : null),
139+
Removed: (d) => (d.removed > 0 ? `-${d.removed}` : null),
140+
Modified: (d) => (d.modified > 0 ? `~${d.modified}` : null),
141+
},
142+
format: {
143+
x: false,
144+
y: false,
145+
fill: true,
146+
},
147+
} as Plot.TipOptions),
148+
),
149+
],
150+
color: {
151+
domain: ['added', 'removed', 'modified', 'unchanged'],
152+
range: ['#22c55e', '#ef4444', '#f59e0b', '#808080'],
153+
},
154+
style: { background: 'transparent' },
155+
} satisfies Partial<Parameters<typeof Plot.plot>[0]>
156+
},
157+
[history, height, slots, offset, n],
158+
)
159+
160+
const handleClick = React.useCallback(
161+
(e: React.MouseEvent<HTMLDivElement>) => {
162+
if (!onVersionClick) return
163+
const container = e.currentTarget
164+
const rect = container.getBoundingClientRect()
165+
const x = e.clientX - rect.left
166+
const width = container.clientWidth
167+
if (width === 0) return
168+
169+
const domainX = (x / width) * slots - 0.5
170+
const slotIndex = Math.round(domainX)
171+
const historyIndex = slotIndex - offset
172+
if (historyIndex < 0 || historyIndex >= n) return
173+
174+
e.preventDefault()
175+
e.stopPropagation()
176+
onVersionClick(history[historyIndex], historyIndex)
177+
},
178+
[onVersionClick, slots, offset, n, history],
179+
)
180+
181+
if (history.length === 0) {
182+
return null
183+
}
184+
185+
return (
186+
<PlotContainer
187+
options={options}
188+
height={height}
189+
onClick={onVersionClick ? handleClick : undefined}
190+
style={onVersionClick ? { cursor: 'pointer' } : undefined}
191+
/>
192+
)
193+
}

0 commit comments

Comments
 (0)