feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens

Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
This commit is contained in:
Tyler
2026-06-22 11:01:58 -06:00
parent 35298907bc
commit 9bca4b608a
54 changed files with 6224 additions and 3871 deletions
+150
View File
@@ -0,0 +1,150 @@
// ---------------------------------------------------------------------------
// AgingBars — AR aging stacked horizontal bar.
//
// One bar split into up to 4 segments by aging bucket (030 / 3160 /
// 6190 / 90+ days). Each segment is coloured along a cool-to-warm ramp
// so the eye reads left-to-right as "freshest to most stale". Below
// the bar, four legend rows show the bucket label, dollar amount, and
// share of total. The component is data-driven — pass any four-bucket
// distribution.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface AgingBucket {
/** Bucket id for keying. */
id: string;
/** Display label (e.g. "030", "3160", "6190", "90+"). */
label: string;
/** Dollar amount outstanding in this bucket. */
amount: number;
/** Tailwind/HSL colour for the segment. */
color: string;
}
export interface AgingBarsProps {
buckets: AgingBucket[];
/** Total AR amount for share calc. Defaults to sum of buckets. */
total?: number;
/** Optional caption shown right-aligned above the bar. */
caption?: string;
}
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
if (sum <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Nothing outstanding.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>Aging buckets</span>
<span>{caption}</span>
</div>
) : null}
{/* The stacked bar */}
<div
className="flex w-full h-3 overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{buckets.map((b, i) => {
const pct = (b.amount / sum) * 100;
if (pct <= 0) return null;
return (
<div
key={b.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: b.color,
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 70
}ms both`,
transformOrigin: "left center",
}}
title={`${b.label}: ${fmt.usd(b.amount)}`}
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
/>
);
})}
</div>
{/* Legend rows */}
<ul className="mt-4 space-y-2">
{buckets.map((b) => {
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
return (
<li
key={b.id}
className="flex items-center justify-between gap-3 text-[12px]"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: b.color }}
/>
<span
className="mono tabular-nums"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{b.label}d
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div
className="relative h-1 w-16 rounded-full overflow-hidden"
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${share}%`,
backgroundColor: b.color,
opacity: 0.7,
}}
/>
</div>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
minWidth: 70,
textAlign: "right",
}}
>
{fmt.usd(b.amount)}
</span>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink-3))",
minWidth: 40,
textAlign: "right",
fontSize: 10.5,
}}
>
{share.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+381
View File
@@ -0,0 +1,381 @@
// ---------------------------------------------------------------------------
// BarChart — grouped vertical bar chart on cream paper.
//
// Renders N series (e.g. billed, received, AR) over M categories (months).
// Each category gets a cluster of bars, one per series. The chart grows
// the bars from 0 on mount with a staggered delay so the statement
// doesn't appear all at once. Hover shows the exact value as a small
// ledger callout. Designed to live INSIDE the paper statement — no dark
// chrome, hairline grid only.
//
// The paper variant: cream background, dark ink, hairline rule in
// --surface-line-soft. Three series uses --accent (billed), --success
// (received), --signal (AR outstanding) so each carries its own
// semantic colour rather than three random hues.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
export interface BarSeries {
/** Stable id used for keying, hover hit-tests, and legend swatches. */
id: string;
/** Display name shown in the legend. */
label: string;
/** Hex/HSL colour used for bar fill + legend swatch. */
color: string;
/** Values aligned with `categories`. */
values: number[];
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
format?: (n: number) => string;
}
export interface BarChartProps {
/** Category labels along the X axis (e.g. month abbreviations). */
categories: string[];
/** Ordered series. First series renders leftmost in each cluster. */
series: BarSeries[];
/** Height in px. Default 260. */
height?: number;
/** Optional y-axis tick formatter. If omitted, the chart self-scales
* to a "round" max and labels with a compact thousands suffix. */
formatY?: (n: number) => string;
/** When true, draws a hairline rule for each y tick. Default true. */
showGrid?: boolean;
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
* to a "round" max with a small headroom. */
yMax?: number;
/** When true, prints the per-cluster total above each month. Default true. */
showTotals?: boolean;
/** Index of the "current" category to highlight (e.g. latest month).
* Adds a subtle background band and an eyebrow above. */
highlightIndex?: number;
}
const PAD_LEFT = 44;
const PAD_RIGHT = 16;
const PAD_TOP = 16;
const PAD_BOTTOM = 28;
const niceMax = (raw: number): number => {
if (raw <= 0) return 1;
const exp = Math.floor(Math.log10(raw));
const base = Math.pow(10, exp);
const norm = raw / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
};
const compact = (n: number): string => {
const abs = Math.abs(n);
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
};
interface Hover {
cat: number;
series: number;
x: number;
y: number;
}
export function BarChart({
categories,
series,
height = 260,
formatY,
showGrid = true,
yMax: yMaxProp,
showTotals = true,
highlightIndex,
}: BarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(800);
const [hover, setHover] = useState<Hover | null>(null);
// Track container width so the SVG reflows with the layout grid.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
setWidth(Math.max(320, Math.floor(w)));
});
ro.observe(el);
setWidth(Math.max(320, Math.floor(el.clientWidth)));
return () => ro.disconnect();
}, []);
const innerW = width - PAD_LEFT - PAD_RIGHT;
const innerH = height - PAD_TOP - PAD_BOTTOM;
const allValues = series.flatMap((s) => s.values);
const rawMax = allValues.length ? Math.max(...allValues) : 1;
// If caller forces a yMax, use it; otherwise compute a "round" max
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
const yTicks = 4;
const clusterW = innerW / Math.max(1, categories.length);
const barGap = 4;
const barW = Math.max(
4,
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
);
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
const fmtY = formatY ?? compact;
// Stagger the bars so they grow from baseline on first paint. We just
// emit a CSS animation-delay per bar via inline style.
const barIndex = (catI: number, serI: number) =>
catI * series.length + serI;
return (
<div ref={wrapRef} className="relative w-full" style={{ height }}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block"
role="img"
aria-label="Monthly billed, received, and AR trend"
onMouseLeave={() => setHover(null)}
>
{/* Y-axis grid + labels */}
{showGrid &&
Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (yMax / yTicks) * i;
const y = yToPx(v);
return (
<g key={i}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
y1={y}
y2={y}
stroke="hsl(30 14% 14% / 0.10)"
strokeWidth={1}
strokeDasharray={i === 0 ? "" : "2 3"}
/>
<text
x={PAD_LEFT - 10}
y={y + 4}
textAnchor="end"
className="mono"
fontSize={11}
fontWeight={500}
style={{ fill: "hsl(var(--surface-ink-2))" }}
>
{fmtY(v)}
</text>
</g>
);
})}
{/* Highlight band on the current month (subtle, like a sticky note) */}
{highlightIndex !== undefined &&
highlightIndex >= 0 &&
highlightIndex < categories.length ? (
<g>
<rect
x={catCenterX(highlightIndex) - clusterW / 2}
y={PAD_TOP}
width={clusterW}
height={innerH}
fill="hsl(36 92% 56% / 0.06)"
/>
<line
x1={catCenterX(highlightIndex)}
x2={catCenterX(highlightIndex)}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke="hsl(36 92% 56% / 0.45)"
strokeWidth={1}
strokeDasharray="2 3"
/>
<text
x={catCenterX(highlightIndex)}
y={PAD_TOP + 12}
textAnchor="middle"
className="mono"
fontSize={9}
fontWeight={600}
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
>
NOW
</text>
</g>
) : null}
{/* Bars */}
{categories.map((cat, ci) =>
series.map((s, si) => {
const v = s.values[ci] ?? 0;
const x =
catCenterX(ci) -
(series.length * barW + (series.length - 1) * barGap) / 2 +
si * (barW + barGap);
const y = yToPx(v);
const h = Math.max(0, PAD_TOP + innerH - y);
const delay = barIndex(ci, si) * 35;
const isHover =
hover?.cat === ci && hover?.series === si ? true : false;
return (
<rect
key={`${cat}-${s.id}`}
x={x}
y={y}
width={barW}
height={h}
rx={1.5}
fill={s.color}
opacity={hover && !isHover ? 0.55 : 1}
style={{
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
}}
onMouseEnter={() =>
setHover({
cat: ci,
series: si,
x: x + barW / 2,
y,
})
}
/>
);
})
)}
{/* X-axis labels */}
{categories.map((cat, i) => (
<text
key={cat}
x={catCenterX(i)}
y={height - 14}
textAnchor="middle"
className="mono"
fontSize={11}
fontWeight={500}
style={{
fill:
highlightIndex === i
? "hsl(var(--surface-ink))"
: "hsl(var(--surface-ink-2))",
}}
>
{cat}
</text>
))}
{/* Per-cluster totals above each month — quick read of the spine. */}
{showTotals &&
categories.map((_, ci) => {
const total = series.reduce(
(s, sr) => s + (sr.values[ci] ?? 0),
0
);
const x = catCenterX(ci);
const topY = yToPx(
Math.max(...series.map((s) => s.values[ci] ?? 0))
);
const labelY = Math.max(14, topY - 8);
return (
<text
key={`total-${ci}`}
x={x}
y={labelY}
textAnchor="middle"
className="display mono"
fontSize={11}
fontWeight={600}
style={{
fill: "hsl(var(--surface-ink-2))",
opacity: 0.75,
}}
>
{fmtY(total)}
</text>
);
})}
{/* Hover callout */}
{hover ? (
(() => {
const s = series[hover.series]!;
const v = s.values[hover.cat] ?? 0;
const catLabel = categories[hover.cat] ?? "";
const text = `${s.label} · ${catLabel}`;
const valueText = s.format ? s.format(v) : fmtY(v);
// Position the chip to the right of the bar; flip left if it
// would overflow the canvas edge.
const flipLeft = hover.x > width - 160;
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
const anchor: "end" | "start" = flipLeft ? "end" : "start";
return (
<g style={{ pointerEvents: "none" }}>
<line
x1={hover.x}
x2={hover.x}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke={s.color}
strokeWidth={1}
strokeDasharray="2 3"
opacity={0.55}
/>
<text
x={chipX}
y={Math.max(14, hover.y - 10)}
textAnchor={anchor}
className="mono"
fontSize={10}
style={{ fill: "hsl(var(--surface-ink-3))" }}
>
{text}
</text>
<text
x={chipX}
y={Math.max(26, hover.y + 2)}
textAnchor={anchor}
className="display mono"
fontSize={16}
style={{ fill: "hsl(var(--surface-ink))" }}
>
{valueText}
</text>
</g>
);
})()
) : null}
</svg>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 flex-wrap">
{series.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: s.color }}
/>
<span
className="mono text-[10.5px] uppercase tracking-[0.16em]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{s.label}
</span>
</div>
))}
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
// ---------------------------------------------------------------------------
// HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers).
//
// Each row gets a label slot on the left, a bar that grows from 0 to
// its value on mount, and a trailing value. Bars share a common scale
// derived from the max value. Designed for 48 rows; collapses to a
// list if rows are empty. Paper-friendly: cream background, dark ink.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface HBarRow {
/** Stable id used as React key. */
id: string;
/** Left label (e.g. provider name). */
label: string;
/** Sub-label under the row (e.g. NPI). Optional. */
sublabel?: string;
/** Bar value. */
value: number;
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
formatted?: string;
/** Optional color override. Defaults to --accent. */
color?: string;
/** Optional leading slot (icon, initials avatar, etc). */
leading?: React.ReactNode;
}
export interface HBarChartProps {
rows: HBarRow[];
/** Bar fill colour when a row doesn't override. Defaults to accent. */
defaultColor?: string;
/** Pixel height of each row. Default 44. */
rowHeight?: number;
/** Compact mode: smaller text, tighter padding. */
compact?: boolean;
/** Optional click handler. */
onRowClick?: (id: string) => void;
}
export function HBarChart({
rows,
defaultColor = "hsl(var(--accent))",
rowHeight = 44,
compact = false,
onRowClick,
}: HBarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [labelW, setLabelW] = useState(180);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
// Reserve ~38% of width for label + leading slot, capped between
// 140 and 260 so bars stay readable on wide and narrow layouts.
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
});
ro.observe(el);
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
return () => ro.disconnect();
}, []);
if (rows.length === 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No data yet.
</div>
);
}
const max = Math.max(...rows.map((r) => r.value), 1);
return (
<div ref={wrapRef} className="w-full">
{rows.map((r, i) => {
const pct = (r.value / max) * 100;
const color = r.color ?? defaultColor;
return (
<div
key={r.id}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(r.id);
}
}
: undefined
}
className={cn(
"group flex items-center gap-3",
compact ? "py-1.5" : "py-2.5",
onRowClick ? "cursor-pointer" : ""
)}
style={{ minHeight: rowHeight }}
>
{/* Leading slot (e.g. initials avatar) */}
{r.leading ? (
<div className="shrink-0">{r.leading}</div>
) : null}
{/* Label column */}
<div
className="shrink-0 min-w-0"
style={{ width: labelW - (r.leading ? 36 : 0) }}
>
<div
className={cn(
"truncate font-medium",
compact ? "text-[12.5px]" : "text-[13.5px]"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{r.label}
</div>
{r.sublabel ? (
<div
className="mono truncate"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
marginTop: 1,
}}
>
{r.sublabel}
</div>
) : null}
</div>
{/* Bar */}
<div className="flex-1 min-w-0 relative">
{/* Track (hairline) */}
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
aria-hidden
/>
<div
className="relative h-[10px] rounded-[2px]"
style={{
width: `${pct}%`,
backgroundColor: color,
opacity: 0.85,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
}}
aria-hidden
/>
{/* Rank tick on the bar */}
<span
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
style={{
left: 6,
color: "hsl(0 0% 100% / 0.92)",
mixBlendMode: "screen",
opacity: pct > 12 ? 1 : 0,
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
{/* Trailing value */}
<div
className={cn(
"shrink-0 text-right mono tabular-nums",
compact ? "text-[12.5px]" : "text-[14px]"
)}
style={{
color: "hsl(var(--surface-ink))",
minWidth: 56,
}}
>
{r.formatted ?? r.value.toLocaleString("en-US")}
</div>
</div>
);
})}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
// ---------------------------------------------------------------------------
// SegmentedBar — thin horizontal status distribution strip.
//
// A single bar split into N color segments showing the share of each
// status. The legend below the bar shows the segment label + count +
// percentage, color-coded to the segment. Designed for 46 statuses.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface Segment {
id: string;
label: string;
count: number;
color: string;
}
export interface SegmentedBarProps {
segments: Segment[];
/** Optional caption shown above the bar (e.g. "Status distribution"). */
caption?: string;
}
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
const total = segments.reduce((s, x) => s + x.count, 0);
if (total <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No claims yet.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{caption}
</div>
) : null}
{/* The bar */}
<div
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{segments.map((s, i) => {
const pct = (s.count / total) * 100;
if (pct <= 0) return null;
return (
<div
key={s.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: s.color,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
}}
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
aria-label={`${s.label}: ${s.count} of ${total}`}
/>
);
})}
</div>
{/* Legend */}
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
{segments.map((s) => {
const pct = (s.count / total) * 100;
if (s.count === 0) return null;
return (
<li
key={s.id}
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: s.color }}
/>
<span
className="truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{s.label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
<span style={{ color: "hsl(var(--surface-ink))" }}>
{fmt.num(s.count)}
</span>
<span
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
minWidth: 30,
textAlign: "right",
}}
>
{pct.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}