feat(manascore): add score trend visualization with sparkline charts

Add history array to schema for tracking score changes over time.
Index page shows inline sparkline + delta, detail page shows larger
area chart with score labels on each data point.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 11:35:12 +01:00
parent 7813e3b4bb
commit d3ae3841d9
5 changed files with 167 additions and 20 deletions

View file

@ -203,6 +203,15 @@ const manascoreCollection = defineCollection({
security: z.number().min(0).max(100),
ux: z.number().min(0).max(100),
}),
// Score history for trend visualization
history: z
.array(
z.object({
date: z.string(), // ISO date string e.g. "2026-03-19"
score: z.number().min(0).max(100),
})
)
.optional(),
// Readiness level
status: z.enum(['prototype', 'alpha', 'beta', 'production', 'mature']),
// Stats

View file

@ -6,6 +6,9 @@ app: 'calendar'
author: 'Till Schneider'
tags: ['audit', 'calendar', 'production-readiness']
score: 97
history:
- { date: '2026-03-19', score: 82 }
- { date: '2026-03-24', score: 97 }
scores:
backend: 95
frontend: 96

View file

@ -6,6 +6,9 @@ app: 'todo'
author: 'Till Schneider'
tags: ['audit', 'todo', 'production-readiness']
score: 96
history:
- { date: '2026-03-19', score: 80 }
- { date: '2026-03-24', score: 96 }
scores:
backend: 94
frontend: 95

View file

@ -101,23 +101,100 @@ function getBarColor(score: number): string {
<p class="text-muted-foreground text-lg">{audit.data.description}</p>
</div>
{/* Overall Score */}
<div
class="border-border/50 mb-8 rounded-xl border bg-gradient-to-br from-white/5 to-white/[0.02] p-6"
>
<div class="flex items-center justify-between">
<div>
<h2 class="text-foreground text-sm font-semibold">Gesamtscore</h2>
<p class="text-muted-foreground text-xs">Gewichteter Durchschnitt aus 8 Kategorien</p>
</div>
<div class="text-center">
<span class={`text-5xl font-bold ${getScoreColor(audit.data.score)}`}>
{audit.data.score}
</span>
<span class="text-muted-foreground block text-sm">/100</span>
</div>
</div>
</div>
{/* Overall Score + Trend */}
{
(() => {
const history = audit.data.history || [
{ date: audit.data.date.toISOString(), score: audit.data.score },
];
const hasTrend = history.length > 1;
const trendDelta = hasTrend ? history[history.length - 1].score - history[0].score : 0;
const minScore = Math.min(...history.map((h: { score: number }) => h.score)) - 5;
const maxScore = Math.max(...history.map((h: { score: number }) => h.score)) + 5;
const range = maxScore - minScore || 1;
const chartWidth = 200;
const chartHeight = 60;
const points = history.map((h: { score: number }, i: number) => {
const x =
history.length > 1 ? (i / (history.length - 1)) * chartWidth : chartWidth / 2;
const y = chartHeight - ((h.score - minScore) / range) * (chartHeight - 4) - 2;
return { x, y, score: h.score, date: h.date };
});
const polyline = points.map((p: { x: number; y: number }) => `${p.x},${p.y}`).join(' ');
const areaPath = `M${points[0].x},${chartHeight} ${points.map((p: { x: number; y: number }) => `L${p.x},${p.y}`).join(' ')} L${points[points.length - 1].x},${chartHeight} Z`;
return (
<div class="border-border/50 mb-8 rounded-xl border bg-gradient-to-br from-white/5 to-white/[0.02] p-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-foreground text-sm font-semibold">Gesamtscore</h2>
<p class="text-muted-foreground text-xs">
Gewichteter Durchschnitt aus 8 Kategorien
</p>
{hasTrend && (
<p
class={`mt-1 text-xs font-medium ${trendDelta >= 0 ? 'text-green-500' : 'text-red-500'}`}
>
{trendDelta > 0 ? '↑' : '↓'} {Math.abs(trendDelta)} Punkte seit erstem
Assessment
</p>
)}
</div>
<div class="flex items-center gap-4">
{hasTrend && (
<svg
width={chartWidth}
height={chartHeight}
class="overflow-visible opacity-80"
>
<path
d={areaPath}
fill={trendDelta >= 0 ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)'}
/>
<polyline
points={polyline}
fill="none"
stroke={trendDelta >= 0 ? '#22c55e' : '#ef4444'}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
{points.map(
(p: { x: number; y: number; score: number; date: string }, i: number) => (
<g>
<circle
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 3}
fill={trendDelta >= 0 ? '#22c55e' : '#ef4444'}
/>
<text
x={p.x}
y={p.y - 8}
text-anchor="middle"
fill="currentColor"
font-size="9"
class="text-muted-foreground"
>
{p.score}
</text>
</g>
)
)}
</svg>
)}
<div class="text-center">
<span class={`text-5xl font-bold ${getScoreColor(audit.data.score)}`}>
{audit.data.score}
</span>
<span class="text-muted-foreground block text-sm">/100</span>
</div>
</div>
</div>
</div>
);
})()
}
{/* Category Scores */}
<div

View file

@ -106,6 +106,24 @@ const statuses = [...new Set(sortedAudits.map((a) => a.data.status))];
const status = statusColors[data.status] || statusColors.alpha;
const scoreColor = getScoreColor(data.score);
// Build sparkline SVG path from history
const history = data.history || [{ date: data.date.toISOString(), score: data.score }];
const sparkWidth = 48;
const sparkHeight = 20;
const minScore = Math.min(...history.map((h: { score: number }) => h.score));
const maxScore = Math.max(...history.map((h: { score: number }) => h.score));
const range = maxScore - minScore || 1;
const sparkPoints = history
.map((h: { score: number }, i: number) => {
const x =
history.length > 1 ? (i / (history.length - 1)) * sparkWidth : sparkWidth / 2;
const y = sparkHeight - ((h.score - minScore) / range) * (sparkHeight - 2) - 1;
return `${x},${y}`;
})
.join(' ');
const hasTrend = history.length > 1;
const trendDelta = hasTrend ? history[history.length - 1].score - history[0].score : 0;
return (
<a
href={`/manascore/${audit.slug}`}
@ -170,10 +188,47 @@ const statuses = [...new Set(sortedAudits.map((a) => a.data.status))];
)}
</div>
{/* Score */}
<div class="flex shrink-0 flex-col items-center">
{/* Score + Trend */}
<div class="flex shrink-0 flex-col items-center gap-1">
<div class={`text-2xl font-bold tabular-nums ${scoreColor}`}>{data.score}</div>
<span class="text-muted-foreground/50 text-[10px]">/100</span>
{hasTrend ? (
<div class="flex items-center gap-1">
<svg width={sparkWidth} height={sparkHeight} class="overflow-visible">
<polyline
points={sparkPoints}
fill="none"
stroke={trendDelta >= 0 ? '#22c55e' : '#ef4444'}
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
{history.map((h: { score: number }, i: number) => {
const x =
history.length > 1
? (i / (history.length - 1)) * sparkWidth
: sparkWidth / 2;
const y =
sparkHeight - ((h.score - minScore) / range) * (sparkHeight - 2) - 1;
return (
<circle
cx={x}
cy={y}
r={i === history.length - 1 ? 2.5 : 1.5}
fill={trendDelta >= 0 ? '#22c55e' : '#ef4444'}
/>
);
})}
</svg>
<span
class={`text-[10px] font-medium ${trendDelta >= 0 ? 'text-green-500' : 'text-red-500'}`}
>
{trendDelta > 0 ? '+' : ''}
{trendDelta}
</span>
</div>
) : (
<span class="text-muted-foreground/50 text-[10px]">/100</span>
)}
</div>
</div>
</a>