import { Link } from "react-router"; import { Badge } from "@/components/ui/badge"; import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts"; import { ruleAt } from "../../domain/allocation.ts"; import { carryMeterSegments, type MeterSegment } from "../meterSegments.ts"; import { Meter } from "./Meter.tsx"; import type { MonthPostRow } from "../../domain/views.ts"; import type { Currency, MonthId } from "../../domain/types.ts"; interface Props { monthId: MonthId; rows: MonthPostRow[]; /** Omitted on read-only mounts; only the month view offers rule editing. */ onChangeRule?: (postId: string) => void; } /** * The month's posts, in two markups: a real table from `sm:` up, a list of * two-line blocks below it. * * NOT one reflowing grid with `role="table"`. At 390px the layout genuinely * stops being a table — it is a stack of two-line blocks — and leaving table * roles on it would describe rows and columns to a screen reader that no * longer exist on screen. Each structure is honest about itself instead. The * figures appear twice in the DOM at any one width, which at six to thirty * posts costs nothing, and both markups read their cells from `postRowCells` * so the numbers are still derived in exactly one place. * * The currency is deliberately absent from every cell: it is stated once, in * the legend, because every figure here is in the dataset's base currency. * Repeating it bought nothing and wrapped "+4,219.61 DKK" onto two lines on a * phone, which is the width a grocery trip is actually entered at. */ interface RowCells { carriedIn: string; carriedInTone: string; allocation: string; charges: string; remaining: string; remainingTone: string; /** What is left once unconfirmed recurring bills are counted too. Always * rendered in the muted ink token, never toned, so `remaining` stays the * one figure the eye lands on first. */ projected: string; segments: MeterSegment[]; } /** * Takes the formatter as an argument rather than calling the hook: this is a * plain function, not a component, and both table markups call it from inside * a map. */ function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells { return { carriedIn: fmt.signedAmount(figures.carriedIn), carriedInTone: figures.carriedIn > 0 ? "text-surplus" : figures.carriedIn < 0 ? "text-overspend" : "", allocation: fmt.amount(figures.allocation), charges: fmt.amount(figures.charges), remaining: fmt.signedAmount(figures.remaining), remainingTone: figures.remaining < 0 ? "text-overspend" : "", projected: fmt.signedAmount(figures.projected), segments: carryMeterSegments(figures), }; } /** * The four colours of the carry meter, named once for the whole table. Sits in * the Section's hint, so it renders inside a `

` — spans only, no divs. */ export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) { return ( All figures in {baseCurrency}. Projected subtracts bills not yet confirmed. ); } function Swatch({ token, label }: { token: string; label: string }) { return ( ); } function PostName({ row: { post, overridden }, monthId, }: { row: MonthPostRow; monthId: MonthId; }) { return ( <> {post.name} {post.archived && ( archived )} {overridden && ( overridden )} {/* A month can carry BOTH badges: a version starting in a month the user also overrode is unusual but legal, and hiding either would obscure why the number is what it is. The override still wins. */} {ruleAt(post, monthId)?.from === monthId && ( rule changes here )} ); } /** * Placed differently in each markup, which is why it is not part of PostName. * In the table it is revealed on hover, beside the name. On a phone there is * no hover, so it has to be permanently visible — and beside the name at that * size it competes with the name itself, so it goes at the end of the quiet * caption line instead. */ function ChangeRuleButton({ postId, onChangeRule, className = "", }: { postId: string; onChangeRule: (postId: string) => void; className?: string; }) { return ( ); } export function PostTable({ monthId, rows, onChangeRule }: Props) { const fmt = useMoneyFormat(); return ( <>

{/* One tbody per post, holding the figures AND their meter. Multiple tbodies are valid, and this is the only way to hover the pair as one block — two loose sibling s cannot express that they belong together, which is exactly how the meter came to look like a divider between two rows rather than part of one. */} {rows.map((row) => { const cells = postRowCells(row, fmt); return ( {/* The meter IS the row divider: tight under its own figures, with the gap below it. */} ); })}
Post Carried in Allocated Spent Remaining Projected
{onChangeRule && ( )} {cells.carriedIn} {cells.allocation} {cells.charges} {cells.remaining} {cells.projected}
); }