HextorUI
block

Report Sections

The four panels of a submission report: score block with radar and table, stage list, performance, and test quality.

npx shadcn@latest add @hextor/report-sections

Source

"use client";

import { useState } from "react";
import { Table2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AXES, axisMeta } from "@/registry/hextor/lib/axes";
import { SkillRadar } from "@/registry/hextor/components/hextor/skill-radar/skill-radar";
import { AxisBars } from "@/registry/hextor/components/hextor/skill-radar/axis-bars";
import {
  resolveLabels as resolveRadarLabels,
  type RadarLabels,
} from "@/registry/hextor/components/hextor/skill-radar/labels";
import { SectionTitle } from "@/registry/hextor/components/hextor/report-sections/section-title";
import {
  resolveLabels,
  type ReportSectionsLabels,
} from "@/registry/hextor/components/hextor/report-sections/labels";
import type { SubmissionScores } from "@/registry/hextor/components/hextor/report-sections/types";

export function ScoreBlock({
  scores,
  radarSize = 252,
  title = "Skill report",
  labels,
  radarLabels,
}: {
  scores: SubmissionScores;
  radarSize?: number;
  title?: string;
  /** Overrides this block's own copy (toggle, table headers, footnotes). */
  labels?: Partial<ReportSectionsLabels>;
  /**
   * Overrides the per-axis name/blurb, forwarded to `SkillRadar`/`AxisBars`
   * and reused for the table's axis column — one Thai axis vocabulary
   * instead of a second copy of it living in this block.
   */
  radarLabels?: Partial<RadarLabels>;
}) {
  const [showTable, setShowTable] = useState(false);
  const copy = resolveLabels(labels);
  const axisCopy = resolveRadarLabels(radarLabels);
  const withheld = scores.axes.filter((a) => a.withheld);
  const maxPossible = AXES.reduce((s, a) => s + a.max, 0);
  // What was actually achievable on this submission. Lower than maxPossible
  // whenever an axis is withheld — e.g. static analysis reports at weight 0
  // until its rule table is calibrated, and perf is withheld on a dev-grade node.
  const scorable = scores.axes
    .filter((a) => !a.withheld)
    .reduce((s, a) => s + a.max, 0);

  return (
    <section className="space-y-3">
      <div className="flex items-center justify-between">
        <SectionTitle>{title}</SectionTitle>
        <Button
          variant="ghost"
          size="sm"
          className="h-6 gap-1 px-1.5 text-[11px]"
          onClick={() => setShowTable((s) => !s)}
        >
          <Table2 className="size-3" />
          {showTable ? copy.score.chartLabel : copy.score.tableLabel}
        </Button>
      </div>

      {/* hero figure — the one number the report leads with. Proportional
          figures, not tabular: at this size tabular-nums makes "72" look loose. */}
      <div className="flex items-end gap-3">
        <span className="text-5xl font-semibold leading-none tracking-tight">
          {Math.round(scores.total)}
        </span>
        <div className="pb-1 text-xs text-muted-foreground">
          <div className="tnum">/ {maxPossible}</div>
          {scores.percentile !== undefined && (
            <div className="tnum">{copy.score.topPercentile(100 - scores.percentile)}</div>
          )}
        </div>
      </div>

      {/*
        Without this line a flawless submission reads as 95/100 and the user goes
        hunting for 5 points they never had a way to earn. Any withheld axis
        lowers the achievable maximum, so state it instead of letting them
        reverse-engineer it from the withheld list below.
      */}
      {scorable < maxPossible && (
        <p className="tnum text-[11px] text-muted-foreground">
          {copy.score.scorableNote.prefix}{" "}
          <span className="font-medium text-foreground">{scorable}</span>{" "}
          {copy.score.scorableNote.unitAfterScorable} — {copy.score.scorableNote.remainingLead}{" "}
          {maxPossible - scorable} {copy.score.scorableNote.remainingTail}
        </p>
      )}

      {showTable ? (
        <table className="w-full text-xs">
          <thead>
            <tr className="border-b text-muted-foreground">
              <th className="py-1 text-left font-normal">{copy.score.table.axis}</th>
              <th className="py-1 text-right font-normal">{copy.score.table.points}</th>
              <th className="py-1 text-right font-normal">{copy.score.table.max}</th>
              <th className="py-1 text-right font-normal">{copy.score.table.percent}</th>
            </tr>
          </thead>
          <tbody>
            {scores.axes.map((a) => {
              const meta = axisMeta(a.axis);
              return (
                <tr key={a.axis} className="border-b last:border-0">
                  <td className="py-1.5">
                    <span className="flex items-center gap-1.5">
                      <span
                        className="size-2 rounded-full"
                        style={{ background: meta.cssVar }}
                      />
                      {axisCopy.axes[a.axis].label}
                    </span>
                  </td>
                  <td className="tnum py-1.5 text-right">
                    {a.withheld ? "—" : a.points.toFixed(1)}
                  </td>
                  <td className="tnum py-1.5 text-right text-muted-foreground">{a.max}</td>
                  <td className="tnum py-1.5 text-right">
                    {a.withheld ? "—" : `${Math.round((a.points / a.max) * 100)}%`}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      ) : (
        <div className="space-y-4">
          <div className="flex justify-center">
            <SkillRadar axes={scores.axes} size={radarSize} labels={radarLabels} />
          </div>
          <AxisBars axes={scores.axes} labels={radarLabels} />
        </div>
      )}

      {withheld.length > 0 && (
        <ul className="space-y-0.5 text-[11px] text-muted-foreground">
          {withheld.map((a) => (
            <li key={a.axis}>
              <span className="text-foreground">{axisCopy.axes[a.axis].label}</span>{" "}
              {copy.score.withheldSuffix} {a.withheldReason}
            </li>
          ))}
        </ul>
      )}

      <p className="text-[10px] text-muted-foreground">
        {copy.score.rubricPrefix} {scores.rubricVersion}
      </p>
    </section>
  );
}

Docs

Four independent panels, exported separately so a report can compose only what it has data for. SectionTitle is shared by three of them and lives in its own file so no panel has to import a sibling panel for an unrelated atom.

TestQualityPanel renders a <Tooltip> and assumes a <TooltipProvider> is mounted above it — put one in your app shell, not around each panel.

The axis names in the score table come from @hextor/skill-radar's defaultLabels, not from @hextor/axes, which is structure-only. That is deliberate: one Thai axis vocabulary shared with the radar rather than two that can drift. Override both at once with radarLabels.

Performance is withheld rather than scored when it cannot be measured honestly — on a dev-grade node the panel says so instead of showing a number the hardware invented.

Dependencies

lucide-reactbadgebuttonprogresstooltip@hextor/viz-tokens@hextor/axes@hextor/format@hextor/skill-radar