ScorecardFenceNode.tsx11.3 KBView on GitHub 'use client';
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { Node } from '@tiptap/core';
import { useState } from 'react';
import { SCORE_MAX, parseScorecard, toneForScore } from '@/modules/documents/coaching/scorecard-fence';
import type { ParsedScorecard, ScoreTone } from '@/modules/documents/coaching/scorecard-fence';
import { decodeFenceAttr } from '@/modules/documents/coaching/fence-attrs';
/**
* Renders a ```scorecard fenced block as a coaching scorecard — a score-banded table and a radar
* chart, offered as two tabs over the SAME rows.
*
* The fence body is an ordinary GFM pipe table, kept verbatim on the node so it round-trips
* losslessly back to markdown on save. That is the point of the design: without this node view the
* same content is still a readable table in Slack, a git diff, or a plain-markdown export.
* Anything that fails to parse renders its body raw rather than throwing inside the editor.
*
* See apps/server/docs/coaching-agent.md §3.2 part E.
*/
/** Row tints. Peter's bands: <3 red, 3–4 amber, >=4 green; `na` muted. */
const TONE_ROW: Record<ScoreTone, string> = {
red: 'bg-red-500/10',
amber: 'bg-amber-500/10',
green: 'bg-emerald-500/10',
muted: 'bg-muted/30',
};
const TONE_TEXT: Record<ScoreTone, string> = {
red: 'text-red-600 dark:text-red-400',
amber: 'text-amber-600 dark:text-amber-400',
green: 'text-emerald-600 dark:text-emerald-400',
muted: 'text-muted-foreground',
};
/** Primary series is emerald; comparison series (e.g. a Team column) are slate. */
const SERIES_COLORS = ['#10b981', '#94a3b8', '#3b82f6', '#f59e0b'];
function ScorecardTable({ card }: { card: ParsedScorecard }) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-1.5 text-left font-semibold">{card.title}</th>
<th className="w-20 px-3 py-1.5 text-right font-semibold">{card.scoreLabel}</th>
{card.seriesKeys.map((k) => (
<th
key={k}
className="w-20 px-3 py-1.5 text-right font-semibold text-muted-foreground"
>
{k}
</th>
))}
{card.reasonLabel ? (
<th className="px-3 py-1.5 text-left font-semibold">{card.reasonLabel}</th>
) : null}
</tr>
</thead>
<tbody>
{card.rows.map((row, i) => {
const tone = toneForScore(row.score);
return (
<tr
key=[redacted]
className={`border-b border-border/50 ${TONE_ROW[tone]}`}
>
<td className="px-3 py-1.5 font-medium">{row.criterion}</td>
<td
className={`px-3 py-1.5 text-right font-semibold tabular-nums ${TONE_TEXT[tone]}`}
>
{/* Always out of the max — a bare "2.6" reads as a percentage or a count. */}
{row.score === null ? 'n/a' : `${row.score}/${SCORE_MAX}`}
</td>
{card.seriesKeys.map((k) => (
<td key={k} className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">
{row.extra[k] === undefined || row.extra[k] === null
? '—'
: `${row.extra[k]}/${SCORE_MAX}`}
</td>
))}
{card.reasonLabel ? (
<td className="px-3 py-1.5 text-muted-foreground">{row.reason}</td>
) : null}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/**
* Radar plus a field-scores panel — the reference design pairs the shape with the numbers, because
* a radar alone tells you which axis is short but never by how much.
*/
function ScorecardRadar({ card }: { card: ParsedScorecard }) {
// Recharts needs one object per axis. `na` rows drop to 0 so the polygon still closes; the
// panel beside it still prints "n/a", which is where that distinction stays legible.
// De-duplicated: a series column headed the same as the score column (or as `criterion`) would
// overwrite it in the datum object and silently drop one polygon.
const series = [card.scoreLabel, ...card.seriesKeys].filter(
(key, i, all) => key !== 'criterion' && all.indexOf(key) === i,
);
const data = card.rows.map((row) => ({
criterion: row.criterion.replace(/^[DM]\d+\s+/, ''),
[card.scoreLabel]: row.score ?? 0,
...Object.fromEntries(card.seriesKeys.map((k) => [k, row.extra[k] ?? 0])),
}));
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="h-64 min-w-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={data} outerRadius="70%">
<PolarGrid stroke="currentColor" className="text-border" />
<PolarAngleAxis
dataKey=[redacted]
tick={{ fontSize: 10, fill: 'currentColor' }}
className="text-muted-foreground"
/>
<PolarRadiusAxis
domain={[0, SCORE_MAX]}
tickCount={SCORE_MAX + 1}
tick={{ fontSize: 9 }}
/>
<Tooltip />
{series.map((key, i) => (
<Radar
key=[redacted]
name={key}
dataKey=[redacted]
stroke={SERIES_COLORS[i % SERIES_COLORS.length]}
fill={SERIES_COLORS[i % SERIES_COLORS.length]}
fillOpacity={i === 0 ? 0.3 : 0.12}
/>
))}
</RadarChart>
</ResponsiveContainer>
</div>
<div className="w-full shrink-0 md:w-72">
<div className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Field scores
</div>
<div className="divide-y divide-border/60">
{card.rows.map((row, i) => {
const tone = toneForScore(row.score);
return (
<div
key=[redacted]
className={`flex items-center justify-between gap-3 px-2 py-1.5 ${
tone === 'red' || tone === 'amber' ? TONE_ROW[tone] : ''
}`}
>
<span className={`min-w-0 flex-1 truncate text-sm ${TONE_TEXT[tone]}`}>
{row.criterion}
</span>
<span className={`shrink-0 text-sm font-semibold tabular-nums ${TONE_TEXT[tone]}`}>
{row.score === null ? 'n/a' : `${row.score}/${SCORE_MAX}`}
</span>
{card.seriesKeys.map((k) => (
<span
key={k}
className="w-12 shrink-0 text-right text-sm tabular-nums text-muted-foreground"
>
{row.extra[k] === undefined || row.extra[k] === null
? '—'
: `${row.extra[k]}/${SCORE_MAX}`}
</span>
))}
</div>
);
})}
</div>
{card.average !== null ? (
<div className="mt-2 flex items-center justify-between border-t border-border px-2 pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{card.title}
</span>
<span
className={`text-sm font-semibold tabular-nums ${TONE_TEXT[toneForScore(card.average)]}`}
>
{card.average}/{SCORE_MAX}
</span>
</div>
) : null}
</div>
</div>
);
}
function ScorecardView({ node }: NodeViewProps) {
const body = (node.attrs.body as string) ?? '';
const card = parseScorecard(body);
const [mode, setMode] = useState<'table' | 'radar'>('table');
if (!card) {
return (
<NodeViewWrapper as="div" className="my-3" contentEditable={false}>
<pre className="rounded-md border border-border bg-muted p-3 font-mono text-xs">{body}</pre>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
as="div"
className="my-3 rounded-lg border border-border bg-background"
contentEditable={false}
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold">{card.title}</span>
{card.average !== null ? (
<span
className={`text-sm font-semibold tabular-nums ${TONE_TEXT[toneForScore(card.average)]}`}
>
{card.average}/{SCORE_MAX}
</span>
) : null}
</div>
<div className="flex gap-1">
{(['table', 'radar'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`cursor-pointer rounded px-2 py-0.5 text-xs capitalize ${
mode === m
? 'bg-muted font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{m}
</button>
))}
</div>
</div>
<div className="p-3">
{mode === 'table' ? <ScorecardTable card={card} /> : <ScorecardRadar card={card} />}
</div>
</NodeViewWrapper>
);
}
const FENCE_RE = /^```scorecard[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)/;
export const ScorecardFenceNode = Node.create({
name: 'scorecardBlock',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return { body: { default: '' } };
},
parseHTML() {
return [
{
tag: 'div[data-scorecard-block]',
getAttrs: (el) => ({
body: decodeFenceAttr((el as HTMLElement).getAttribute('data-scorecard-block')),
}),
},
];
},
renderHTML({ node }) {
return [
'div',
{ 'data-scorecard-block': encodeURIComponent((node.attrs.body as string) ?? '') },
];
},
addNodeView() {
return ReactNodeViewRenderer(ScorecardView);
},
// Must tokenize before StarterKit's generic fenced-code rule claims the `scorecard` language.
// Custom extension tokenizers run before the defaults — the same mechanism DashboardFenceNode
// and CalloutNode rely on.
markdownTokenizer: {
name: 'scorecardBlock',
level: 'block',
start(src: string) {
const idx = src.indexOf('```scorecard');
return idx >= 0 ? idx : -1;
},
tokenize(src: string): MarkdownToken | undefined {
const match = FENCE_RE.exec(src);
if (!match) return undefined;
return { type: 'scorecardBlock', raw: match[0], body: match[1] };
},
},
parseMarkdown(token=[redacted], helpers: MarkdownParseHelpers) {
return helpers.createNode('scorecardBlock', { body: token.body ?? '' }, []);
},
renderMarkdown(node: { attrs?: Record<string, unknown> }) {
return '```scorecard\n' + ((node.attrs?.body as string) ?? '') + '\n```';
},
});