feat(month): rethink the header, post rows and carry meter
Sub-project 2 of 4 of the visual redesign. Spec in docs/superpowers/specs/2026-09-02-month-view-design.md; the owner chose each of the three directions from concrete alternatives. Three problems, all found by looking at the rendered page rather than at the code: - The totals block was four peer figures with no relationship between them, and the income *input* sat in the same visual register as three numbers derived from it. Nothing answered PRODUCT.md's second question at the month level: with Transport at -3,200 you had to read down the Remaining column to find out. It now reads income -> allocation meter -> spend, with the overspent-post count stated in both directions. - The carry meter rendered 21px below its own row and 22px above the next, so it read as a divider rather than as a property of a post. One tbody per post now holds the figures AND their meter, which is both what pulls it tight under its own row and the only way to hover the pair as one block. - Phone width was broken and nothing recorded it: the 5-column table overflowed at 390px with Spent and Remaining off-screen, and every figure wrapped its currency onto a second line. Below sm: each post is now a two-line block, and the currency is stated once in the legend because every figure in that table is base currency. Meter geometry moves to src/ui/meterSegments.ts, pure and tested, with <Meter> as the only thing that paints it. The allocation meter scales to max(income, allocated) so over-allocating overflows instead of capping at a full bar — over-budget is always allowed, so it has to be drawable. Negatives clamp rather than invert: a segment of negative width puts the gradient's stops out of order, which paints something arbitrary instead of failing. Rejected: one reflowing CSS grid carrying role="table". It deduplicates the markup, but at 390px the layout genuinely is not a table any more, and table roles would describe rows and columns to a screen reader that no longer exist on screen. Verified: 443 tests, tsc clean, build clean, and read in both themes at 1280px and 390px. Not verified, because it is not verifiable here — whether it reads better than what it replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bd5a01e03e
commit
7b71811262
10 files changed
+870
-153
No files matched your search
@@ -5,16 +5,18 @@ import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { monthView, type MonthViewModel } from "../../domain/views.ts";
|
||||
import { addMonths } from "../../domain/months.ts";
|
||||
import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
|
||||
import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
|
||||
import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { PostTable } from "../components/PostTable.tsx";
|
||||
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { Meter } from "../components/Meter.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts";
|
||||
import { groupPurchasesByDate } from "../purchaseGroups.ts";
|
||||
|
||||
export function MonthRoute() {
|
||||
@@ -54,8 +56,14 @@ export function MonthRoute() {
|
||||
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
|
||||
</header>
|
||||
|
||||
{/* Income is the origin of every figure below it, so the block reads
|
||||
left to right as one statement: what came in, how much of it is
|
||||
committed, what is left to commit — then, under a rule, what has
|
||||
actually been spent and whether any post is behind. The four peer
|
||||
figures this replaced stated the same numbers at the same weight and
|
||||
answered neither question. */}
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-10">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
Income this month ({base})
|
||||
@@ -74,31 +82,52 @@ export function MonthRoute() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
title={
|
||||
view.unallocated < 0
|
||||
? "Allocations exceed this month's income. This is allowed."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
<Meter
|
||||
segments={allocationMeterSegments(view.income, view.totalAllocation)}
|
||||
className="h-1.5 rounded-full"
|
||||
/>
|
||||
<p className="text-xs text-budget-ink-muted">{allocationCaption(view)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
|
||||
<dl>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
title={
|
||||
view.unallocated < 0
|
||||
? "Allocations exceed this month's income. This is allowed."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
{/* Rendered in both directions. "No posts overspent" is a real answer
|
||||
to the app's second question, and a line that only appears when
|
||||
something is wrong moves everything above it when it arrives. */}
|
||||
<p
|
||||
className={`text-sm ${
|
||||
view.overspentCount > 0 ? "text-overspend" : "text-budget-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{overspentLabel(view.overspentCount)}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Posts" className="overflow-hidden">
|
||||
<PostTable
|
||||
monthId={monthId}
|
||||
baseCurrency={base}
|
||||
rows={view.rows}
|
||||
onChangeRule={setChangingRuleFor}
|
||||
/>
|
||||
<Section
|
||||
title="Posts"
|
||||
hint={<PostTableLegend baseCurrency={base} />}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<PostTable monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
|
||||
</Section>
|
||||
|
||||
{changingPost && (
|
||||
@@ -201,6 +230,21 @@ function PurchaseRow({
|
||||
);
|
||||
}
|
||||
|
||||
function allocationCaption(view: MonthViewModel): string {
|
||||
const percent = allocatedPercentOfIncome(view.income, view.totalAllocation);
|
||||
if (percent === null) {
|
||||
return view.totalAllocation > 0
|
||||
? "Allocated against no recorded income."
|
||||
: "No income recorded for this month yet.";
|
||||
}
|
||||
return `${percent}% of income allocated`;
|
||||
}
|
||||
|
||||
function overspentLabel(count: number): string {
|
||||
if (count === 0) return "No posts overspent";
|
||||
return `${count} post${count === 1 ? "" : "s"} overspent`;
|
||||
}
|
||||
|
||||
/** "2026-09" reads as a key; "September 2026" reads as a month. */
|
||||
function monthLabel(monthId: MonthId): string {
|
||||
const [year, month] = monthId.split("-");
|
||||
|
||||
Reference in new issue
Block a user