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```';
  },
});