mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 00:39:39 +02:00
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:
parent
7813e3b4bb
commit
d3ae3841d9
5 changed files with 167 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue