feat(ui): design foundation — two themes, shipped type, one card
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system already existed and was never wired up. src/index.css defined --paper, --ink and a muted teal with a contract document behind them, while the app painted itself with shadcn's default white and near-black. Those tokens reached the screen in exactly two places — the carry meter and the +/- number colours. TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per theme, and shadcn's whole variable set points at it, so every primitive inherits both themes rather than being fought. Light is cool paper with ink navy; dark is deep slate, never pure black, because a black ground makes the hairlines and the carry meter float. The brand teal is --primary, NOT --accent. That is precisely what the old bug got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with --accent-foreground, so a saturated colour there turns every hover into a coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule mechanical — redefining either half of a shadcn pair without the other fails the build, in any block. CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on both of their backgrounds. Verified afterwards by reading the tokens back out of the live browser in both themes rather than trusting the arithmetic. TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler resolves every url() from disk and inlines woff2 as base64 — relative or absolute, and a "file" loader override does not stop it — which made the stylesheet 303 KB of render-blocking bytes and defeated the unicode-range subsetting entirely. They are injected at boot from one definition instead, which is the only arrangement identical in dev and in a subpath build. THEME TOGGLE cycles system / light / dark, remembers the choice in localStorage (a per-device display preference does not belong in the exported budget), keeps following the OS while set to "system", and is applied by a tiny inline script before first paint so a dark-theme user never sees a white flash. Every storage access is wrapped: localStorage throws outright in some configurations, and a theme preference must never stop the app booting. A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently killed the carry meter. An undefined custom property inside linear-gradient() does not warn, does not throw and does not fall back — the declaration is invalid, so the element paints nothing. It looked like a missing feature. Only pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any TypeScript file references a token src/index.css does not define. Also: one Section/Stat card treatment applied across month, year, summary and settings; quiet uppercase table headers; row hover; the rule-editing and purchase-row actions revealed on hover instead of six always-on links; a scroll fade on the year matrix so it reads as "more to come" rather than clipped; and "September 2026" instead of "2026-09" as the month heading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
11e9405203
commit
bb3c661ef6
30 files changed
+1387
-288
No files matched your search
+46
-15
@@ -9,26 +9,57 @@ import { SummaryRoute } from "./routes/SummaryRoute.tsx";
|
||||
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
|
||||
import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
|
||||
import { ThemeToggle } from "./components/ThemeToggle.tsx";
|
||||
|
||||
/**
|
||||
* One nav item. The active route is marked with weight and an accent
|
||||
* underline rather than colour alone, so it does not depend on colour vision,
|
||||
* and the underline is drawn on a fixed-height pseudo-row so switching tabs
|
||||
* never shifts the header's layout.
|
||||
*/
|
||||
function NavItem({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"relative rounded-md px-2.5 py-1.5 transition-colors",
|
||||
"after:absolute after:inset-x-2.5 after:-bottom-px after:h-0.5 after:rounded-full",
|
||||
isActive
|
||||
? "font-semibold text-budget-ink after:bg-budget-accent"
|
||||
: "text-budget-ink-muted hover:text-budget-ink hover:bg-accent after:bg-transparent",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRoutes() {
|
||||
const year = currentMonth.slice(0, 4);
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<nav className="flex gap-4 border-b px-6 py-3 text-sm">
|
||||
<NavLink to={`/month/${currentMonth}`} className="hover:underline">
|
||||
Month
|
||||
</NavLink>
|
||||
<NavLink to={`/year/${year}`} className="hover:underline">
|
||||
Year
|
||||
</NavLink>
|
||||
<NavLink to="/summary" className="hover:underline">
|
||||
Summary
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className="ml-auto hover:underline">
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
{/* Sticky because the month view is long and the nav is how you leave
|
||||
it. Translucent with a blur so the paper ground still reads as one
|
||||
continuous sheet scrolling underneath. */}
|
||||
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
|
||||
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
|
||||
Budget<span className="text-budget-accent"> 2.0</span>
|
||||
</span>
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<NavItem to={`/month/${currentMonth}`}>Month</NavItem>
|
||||
<NavItem to={`/year/${year}`}>Year</NavItem>
|
||||
<NavItem to="/summary">Summary</NavItem>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<RouteErrorBoundaryReset>
|
||||
<Routes>
|
||||
<Route path="/month/:monthId" element={<MonthRoute />} />
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
|
||||
* - --budget-accent : spend within this month's allocation, filled from
|
||||
* the track's left edge
|
||||
* - --rule : the unspent remainder of the allocation track
|
||||
* - --budget-rule : the unspent remainder of the allocation track
|
||||
* - --overspend : spend beyond the allocation, past the track's end
|
||||
*/
|
||||
function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
@@ -34,7 +34,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
const scale = surplus + Math.max(figures.allocation, figures.charges);
|
||||
|
||||
if (scale <= 0) {
|
||||
return "var(--rule)";
|
||||
return "var(--budget-rule)";
|
||||
}
|
||||
|
||||
const pSurplus = (surplus / scale) * 100;
|
||||
@@ -45,7 +45,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
`linear-gradient(to right, ` +
|
||||
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
|
||||
`var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` +
|
||||
`var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` +
|
||||
`var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` +
|
||||
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
|
||||
);
|
||||
}
|
||||
@@ -64,20 +64,20 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
<th className="py-2 pl-6 text-right">Carried in</th>
|
||||
<th className="py-2 pl-6 text-right">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right">Spent</th>
|
||||
<th className="py-2 pl-6 text-right">Remaining</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ post, figures, overridden }) => (
|
||||
<Fragment key={post.id}>
|
||||
<tr>
|
||||
<td className="py-2">
|
||||
<tr className="group transition-colors hover:bg-accent/60">
|
||||
<td className="py-2.5">
|
||||
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
|
||||
{post.name}
|
||||
</Link>
|
||||
@@ -111,7 +111,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
{onChangeRule && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
|
||||
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => onChangeRule(post.id)}
|
||||
>
|
||||
change from here
|
||||
@@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 pl-6 text-right ${
|
||||
className={`font-money py-2.5 pl-6 text-right ${
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
@@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 pl-6 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.allocation, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 pl-6 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.charges, baseCurrency)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 pl-6 text-right font-medium ${
|
||||
className={`font-money py-2.5 pl-6 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
/** Omitted when the card's contents are self-explanatory (a toolbar). */
|
||||
title?: string;
|
||||
/** Sits opposite the title: a primary action, a mode switch. */
|
||||
action?: ReactNode;
|
||||
/** One line under the title. Long explanations belong in the body. */
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one card in the app. Every screen is a stack of these on the paper
|
||||
* ground, which is what stops elevation, radius and padding drifting apart
|
||||
* screen by screen — the previous design defined a card treatment in a
|
||||
* contract document and then never applied it anywhere.
|
||||
*
|
||||
* The `.budget-card` class carries the visual treatment (see src/index.css) so
|
||||
* light and dark elevation differ where they must: a drop shadow is invisible
|
||||
* on a dark ground, where a lighter surface plus a hairline does the work.
|
||||
*/
|
||||
export function Section({ title, action, hint, children, className = "" }: Props) {
|
||||
return (
|
||||
<section className={`budget-card p-4 sm:p-5 ${className}`}>
|
||||
{(title || action) && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="text-base leading-tight">{title}</h2>}
|
||||
{hint && (
|
||||
<p className="mt-1 text-xs leading-relaxed text-budget-ink-muted">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex shrink-0 items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single figure with its label. Used for the month's totals, where the
|
||||
* numbers matter more than the words: the label is small and quiet, the figure
|
||||
* is large and mono so a column of them lines up.
|
||||
*/
|
||||
export function Stat({
|
||||
label,
|
||||
children,
|
||||
tone = "default",
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
tone?: "default" | "surplus" | "overspend";
|
||||
title?: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "overspend" ? "text-overspend" : tone === "surplus" ? "text-surplus" : "";
|
||||
return (
|
||||
<div className="min-w-0" title={title}>
|
||||
<dt className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={`font-money mt-0.5 text-lg leading-tight ${toneClass}`}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
applyResolvedTheme,
|
||||
prefersDark,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
storeThemePreference,
|
||||
type ThemePreference,
|
||||
} from "../theme.ts";
|
||||
|
||||
const ORDER: ThemePreference[] = ["system", "light", "dark"];
|
||||
|
||||
const LABEL: Record<ThemePreference, string> = {
|
||||
system: "Theme: follows your system",
|
||||
light: "Theme: light",
|
||||
dark: "Theme: dark",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cycles system -> light -> dark. Three states rather than a two-way switch
|
||||
* because "follow the system" is a real answer and the only way to get back to
|
||||
* it, once you have chosen, is to have somewhere to choose it.
|
||||
*
|
||||
* While the preference is "system" this keeps listening, so the app follows
|
||||
* the OS switching to dark at sunset without a reload.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const [preference, setPreference] = useState<ThemePreference>(readThemePreference);
|
||||
|
||||
useEffect(() => {
|
||||
applyResolvedTheme(resolveTheme(preference, prefersDark()));
|
||||
if (preference !== "system") return;
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const follow = () => applyResolvedTheme(resolveTheme("system", media.matches));
|
||||
media.addEventListener("change", follow);
|
||||
return () => media.removeEventListener("change", follow);
|
||||
}, [preference]);
|
||||
|
||||
function cycle() {
|
||||
const next = ORDER[(ORDER.indexOf(preference) + 1) % ORDER.length]!;
|
||||
setPreference(next);
|
||||
storeThemePreference(next);
|
||||
}
|
||||
|
||||
const resolved = resolveTheme(preference, prefersDark());
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycle}
|
||||
title={`${LABEL[preference]}. Click to change.`}
|
||||
aria-label={`${LABEL[preference]}. Click to change.`}
|
||||
className="grid size-8 place-items-center rounded-md text-budget-ink-muted transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{preference === "system" ? (
|
||||
<MonitorGlyph />
|
||||
) : resolved === "dark" ? (
|
||||
<MoonGlyph />
|
||||
) : (
|
||||
<SunGlyph />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Inline so the app ships no icon library for three glyphs. currentColor
|
||||
throughout, so they follow the theme without a second set of tokens. */
|
||||
|
||||
function SunGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="3.1" />
|
||||
<path d="M8 1.4v1.5M8 13.1v1.5M1.4 8h1.5M13.1 8h1.5M3.3 3.3l1.1 1.1M11.6 11.6l1.1 1.1M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<path d="M13.2 10.1A5.6 5.6 0 0 1 5.9 2.8a5.6 5.6 0 1 0 7.3 7.3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<rect x="1.6" y="2.6" width="12.8" height="8.4" rx="1.3" />
|
||||
<path d="M5.6 13.4h4.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -15,63 +15,72 @@ interface Props {
|
||||
*/
|
||||
export function YearMatrix({ view, mode }: Props) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[84rem] text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 pl-4 text-right font-normal whitespace-nowrap">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pl-4 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
// Thirteen columns of figures cannot fit any realistic card, so the table
|
||||
// scrolls. The fade tells you that: without it the matrix just appears to
|
||||
// end mid-number, which reads as clipping rather than as more to come.
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[84rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 pl-4 text-right font-medium whitespace-nowrap">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pl-4 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-budget-rule text-budget-ink-muted">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-budget-raised to-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { Glob } from "bun";
|
||||
|
||||
/**
|
||||
* Every custom property referenced from TypeScript through a CSS var()
|
||||
* expression must name a token that actually exists in src/index.css.
|
||||
*
|
||||
* This exists because renaming `--rule` to `--budget-rule` silently broke the
|
||||
* carry meter — the signature element of the month view. An undefined custom
|
||||
* property inside a `linear-gradient()` does not warn, does not throw and does
|
||||
* not fall back: the whole declaration is invalid, so the element simply
|
||||
* paints nothing. It looked like a missing feature, not a bug, and only pixel
|
||||
* sampling a screenshot found it.
|
||||
*
|
||||
* Tokens set by a third party at runtime are exempt — Radix writes
|
||||
* `--radix-*` onto its own elements, and they are never in our stylesheet.
|
||||
*/
|
||||
const RUNTIME_PROVIDED = /^--radix-/;
|
||||
|
||||
test("no TypeScript file references a CSS token that does not exist", async () => {
|
||||
const css = await Bun.file("src/index.css").text();
|
||||
const defined = new Set(
|
||||
[...css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]!),
|
||||
);
|
||||
|
||||
const missing: string[] = [];
|
||||
for await (const file of new Glob("src/**/*.{ts,tsx}").scan(".")) {
|
||||
const source = await Bun.file(file).text();
|
||||
for (const match of source.matchAll(/var\((--[a-z0-9-]+)\)/g)) {
|
||||
const token = match[1]!;
|
||||
if (RUNTIME_PROVIDED.test(token) || defined.has(token)) continue;
|
||||
const line = source.slice(0, match.index).split("\n").length;
|
||||
missing.push(`${file}:${line} references ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
+104
-73
@@ -14,6 +14,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { PostTable } from "../components/PostTable.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function MonthRoute() {
|
||||
const { monthId = "" } = useParams();
|
||||
@@ -27,56 +28,53 @@ export function MonthRoute() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center justify-between rounded border border-destructive p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<span>Could not save: {error}</span>
|
||||
<button onClick={clearError} className="underline">
|
||||
<button onClick={clearError} className="underline underline-offset-2">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
|
||||
← {addMonths(monthId, -1)}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{monthId}</h1>
|
||||
<Link to={`/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
|
||||
{addMonths(monthId, 1)} →
|
||||
</Link>
|
||||
{/* The month is the app's primary coordinate, so it gets the largest
|
||||
type on the page and the two ways of moving sit either side of it. */}
|
||||
<header className="flex items-baseline gap-3">
|
||||
<MonthStep to={`/month/${addMonths(monthId, -1)}`} label={addMonths(monthId, -1)} back />
|
||||
<h1 className="text-2xl">{monthLabel(monthId)}</h1>
|
||||
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="income">Income this month ({base})</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money w-40"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// Read the DOM value NOW: mutate() defers behind the write queue,
|
||||
// by which time React has reset this input to the committed value.
|
||||
const amount = Number(event.target.value) || 0;
|
||||
mutate((draft) => setIncome(draft, monthId, { amount, currency: base }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalAllocation, base)}</dd>
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
|
||||
<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})
|
||||
</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money h-10 w-44 text-lg"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// Read the DOM value NOW: mutate() defers behind the write queue,
|
||||
// by which time React has reset this input to the committed value.
|
||||
const amount = Number(event.target.value) || 0;
|
||||
mutate((draft) => setIncome(draft, monthId, { amount, currency: base }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Unallocated</dt>
|
||||
<dd
|
||||
className={`font-money ${view.unallocated < 0 ? "text-overspend" : ""}`}
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<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."
|
||||
@@ -84,17 +82,19 @@ export function MonthRoute() {
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Stat>
|
||||
</dl>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Posts" className="overflow-hidden">
|
||||
<PostTable
|
||||
monthId={monthId}
|
||||
baseCurrency={base}
|
||||
rows={view.rows}
|
||||
onChangeRule={setChangingRuleFor}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{changingPost && (
|
||||
<RuleFromMonth
|
||||
@@ -109,52 +109,83 @@ export function MonthRoute() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Purchases</h2>
|
||||
<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />
|
||||
</div>
|
||||
<ul className="divide-y text-sm">
|
||||
<Section
|
||||
title="Purchases"
|
||||
action={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
|
||||
>
|
||||
<ul className="divide-y divide-budget-rule text-sm">
|
||||
{dataset.purchases
|
||||
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
|
||||
.map((purchase) => {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li key={purchase.id} className="flex items-center gap-3 py-2">
|
||||
<span className="flex-1">
|
||||
<li
|
||||
key={purchase.id}
|
||||
className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{purchase.note}</span>
|
||||
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="text-xs text-muted-foreground">financed</span>
|
||||
<span className="rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
|
||||
financed
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money">
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
{/* Held at a fixed width so revealing them on hover cannot
|
||||
shift the amounts, which are the column being read. */}
|
||||
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "2026-09" reads as a key; "September 2026" reads as a month. */
|
||||
function monthLabel(monthId: MonthId): string {
|
||||
const [year, month] = monthId.split("-");
|
||||
const names = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const name = names[Number(month) - 1];
|
||||
return name ? `${name} ${year}` : monthId;
|
||||
}
|
||||
|
||||
function MonthStep({ to, label, back = false }: { to: string; label: string; back?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{back ? `\u2190 ${label}` : `${label} \u2192`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import { CurrencySection } from "./settings/CurrencySection.tsx";
|
||||
import { PostsSection } from "./settings/PostsSection.tsx";
|
||||
import { DataSection } from "./settings/DataSection.tsx";
|
||||
|
||||
/**
|
||||
* Each section is its own card. They are unrelated settings — money, posts,
|
||||
* and the export/import of everything — and running them together down one
|
||||
* column, as before, made the destructive import controls look like a
|
||||
* continuation of the harmless ones.
|
||||
*/
|
||||
export function SettingsRoute() {
|
||||
return (
|
||||
<div className="max-w-4xl space-y-10">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="max-w-4xl space-y-5">
|
||||
<h1 className="text-2xl">Settings</h1>
|
||||
<CurrencySection />
|
||||
<PostsSection />
|
||||
<DataSection />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
|
||||
import { formatAmount, formatMoney } from "../format.ts";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function SummaryRoute() {
|
||||
const dataset = useDataset();
|
||||
@@ -19,9 +20,10 @@ export function SummaryRoute() {
|
||||
const difference = Number((view.totalIncome - view.totalCharges).toFixed(2));
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Summary</h1>
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl">Summary</h1>
|
||||
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="from">From</Label>
|
||||
@@ -41,52 +43,49 @@ export function SummaryRoute() {
|
||||
>
|
||||
All time
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Income</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalIncome, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Difference</dt>
|
||||
<dd className={`font-money ${difference < 0 ? "text-overspend" : ""}`}>
|
||||
{formatMoney(difference, base)}
|
||||
</dd>
|
||||
</div>
|
||||
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
|
||||
{formatMoney(difference, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={groupBy === "post" ? "By post" : "By month"}
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 text-right">Spent</th>
|
||||
<th className="py-2 text-right">Share</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupBy === "post"
|
||||
? view.byPost.map((entry) => (
|
||||
<tr key={entry.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">{entry.post.name}</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2.5">{entry.post.name}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -94,14 +93,14 @@ export function SummaryRoute() {
|
||||
</tr>
|
||||
))
|
||||
: view.byMonth.map((entry) => (
|
||||
<tr key={entry.monthId} className="border-b last:border-0">
|
||||
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/month/${entry.monthId}`} className="hover:underline">
|
||||
{entry.monthId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -110,6 +109,7 @@ export function SummaryRoute() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-23
@@ -5,6 +5,7 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { yearView } from "../../domain/views.ts";
|
||||
import { formatMoney } from "../format.ts";
|
||||
import { YearMatrix } from "../components/YearMatrix.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function YearRoute() {
|
||||
const { year = "" } = useParams();
|
||||
@@ -15,35 +16,49 @@ export function YearRoute() {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-baseline gap-3">
|
||||
<Link
|
||||
to={`/year/${numericYear - 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
← {numericYear - 1}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{year}</h1>
|
||||
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
|
||||
<h1 className="text-2xl">{year}</h1>
|
||||
<Link
|
||||
to={`/year/${numericYear + 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{numericYear + 1} →
|
||||
</Link>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Income {formatMoney(view.totalIncome, base)} · spent{" "}
|
||||
{formatMoney(view.totalCharges, base)}
|
||||
</p>
|
||||
<Section>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</section>
|
||||
<Section
|
||||
title="Posts by month"
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -35,8 +36,7 @@ export function CurrencySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Currency</h2>
|
||||
<Section title="Currency">
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
@@ -58,8 +58,8 @@ export function CurrencySection() {
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
@@ -133,6 +133,6 @@ export function CurrencySection() {
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../../export/json.ts";
|
||||
import { buildOds, odsFilename } from "../../../export/ods.ts";
|
||||
import type { Dataset } from "../../../domain/types.ts";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
|
||||
export function DataSection() {
|
||||
const dataset = useDataset();
|
||||
@@ -93,12 +94,10 @@ export function DataSection() {
|
||||
const counts = describeDataset(dataset);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Your data</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything lives in this browser. Export regularly — it is both your
|
||||
backup and how you move your budget to another device.
|
||||
</p>
|
||||
<Section
|
||||
title="Your data"
|
||||
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
|
||||
>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportJson}>
|
||||
@@ -148,6 +147,6 @@ export function DataSection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
||||
import { ruleAt } from "../../../domain/allocation.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { RuleHistory } from "../../components/RuleHistory.tsx";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
|
||||
export function PostsSection() {
|
||||
const dataset = useDataset();
|
||||
@@ -34,22 +35,17 @@ export function PostsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Posts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A post's allocation is a dated series: each rule applies from its own
|
||||
month onward until the next one takes over, so changing it never
|
||||
rewrites what earlier months got. Click a rule to see and edit that
|
||||
history. Any single month can still override its own allocation from
|
||||
the month view. Percentages may total more than 100%.
|
||||
</p>
|
||||
<Section
|
||||
title="Posts"
|
||||
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
|
||||
>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Display currency</th>
|
||||
<th className="py-2">Rule</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Display currency</th>
|
||||
<th className="py-2 font-medium">Rule</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -121,7 +117,7 @@ export function PostsSection() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
Archiving hides a post from new purchases while keeping its history. Posts
|
||||
are never deleted, because purchases reference them.
|
||||
</p>
|
||||
@@ -144,6 +140,6 @@ export function PostsSection() {
|
||||
Add post
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
// Bun can run several test files in one process and other files register
|
||||
// happy-dom too — guard so they can all run together.
|
||||
if (!GlobalRegistrator.isRegistered) {
|
||||
GlobalRegistrator.register();
|
||||
}
|
||||
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import {
|
||||
THEME_KEY,
|
||||
applyResolvedTheme,
|
||||
isThemePreference,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
storeThemePreference,
|
||||
} from "./theme.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
describe("resolving a preference to an actual theme", () => {
|
||||
test("an explicit choice ignores the system setting entirely", () => {
|
||||
expect(resolveTheme("light", true)).toBe("light");
|
||||
expect(resolveTheme("dark", false)).toBe("dark");
|
||||
});
|
||||
|
||||
test("'system' follows the operating system", () => {
|
||||
expect(resolveTheme("system", true)).toBe("dark");
|
||||
expect(resolveTheme("system", false)).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the stored preference", () => {
|
||||
test("defaults to following the system when nothing is stored", () => {
|
||||
expect(readThemePreference()).toBe("system");
|
||||
});
|
||||
|
||||
test("round-trips each preference", () => {
|
||||
for (const preference of ["light", "dark", "system"] as const) {
|
||||
storeThemePreference(preference);
|
||||
expect(readThemePreference()).toBe(preference);
|
||||
}
|
||||
});
|
||||
|
||||
// A value can only be junk if something else wrote it, or the user edited
|
||||
// it. Following the system beats throwing during boot.
|
||||
test("junk in storage falls back to following the system", () => {
|
||||
localStorage.setItem(THEME_KEY, "solarized");
|
||||
expect(readThemePreference()).toBe("system");
|
||||
});
|
||||
|
||||
test("only the three real preferences are accepted", () => {
|
||||
expect(isThemePreference("light")).toBe(true);
|
||||
expect(isThemePreference("dark")).toBe(true);
|
||||
expect(isThemePreference("system")).toBe(true);
|
||||
expect(isThemePreference("Dark")).toBe(false);
|
||||
expect(isThemePreference(null)).toBe(false);
|
||||
expect(isThemePreference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applying a theme", () => {
|
||||
test("dark adds the class shadcn's palette is keyed on, light removes it", () => {
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
applyResolvedTheme("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
test("applying the same theme twice is not a toggle", () => {
|
||||
applyResolvedTheme("dark");
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* index.html carries a tiny inline script that applies the stored theme before
|
||||
* the stylesheet paints, so a dark-theme user never sees a white flash. It
|
||||
* cannot import this module, so it repeats the key and the logic — and this is
|
||||
* what stops the two drifting apart.
|
||||
*/
|
||||
test("the no-flash script in index.html agrees with this module", async () => {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
expect(html).toContain(THEME_KEY);
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("dark");
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Theme preference: light, dark, or follow the system.
|
||||
*
|
||||
* Stored in localStorage rather than in the dataset, deliberately. The dataset
|
||||
* is the owner's budget — it is exported, imported and versioned, and a
|
||||
* per-device display choice does not belong in it. Importing a backup taken on
|
||||
* a desktop should not flip a phone to dark.
|
||||
*
|
||||
* Every storage access is wrapped: localStorage THROWS on access in some
|
||||
* configurations (a browser set to block site data, some private modes), not
|
||||
* merely returning null, and a theme preference must never be the reason the
|
||||
* app fails to boot.
|
||||
*/
|
||||
|
||||
export const THEME_KEY = "budget2:theme";
|
||||
|
||||
/** What the user chose. */
|
||||
export type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
/** What that resolves to right now. Only these two reach the DOM. */
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
|
||||
const PREFERENCES: readonly string[] = ["light", "dark", "system"];
|
||||
|
||||
export function isThemePreference(value: unknown): value is ThemePreference {
|
||||
return typeof value === "string" && PREFERENCES.includes(value);
|
||||
}
|
||||
|
||||
export function readThemePreference(): ThemePreference {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
return isThemePreference(stored) ? stored : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function storeThemePreference(preference: ThemePreference): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, preference);
|
||||
} catch {
|
||||
// A preference that cannot be remembered is a small loss; a crash is not.
|
||||
}
|
||||
}
|
||||
|
||||
export function prefersDark(): boolean {
|
||||
try {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
preference: ThemePreference,
|
||||
systemPrefersDark: boolean,
|
||||
): ResolvedTheme {
|
||||
if (preference === "system") return systemPrefersDark ? "dark" : "light";
|
||||
return preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* shadcn's dark palette is keyed on a `.dark` class rather than a media query
|
||||
* (`@custom-variant dark (&:is(.dark *))` in styles/globals.css), so this
|
||||
* class is the single switch for both shadcn's variables and the app's own
|
||||
* `--budget-*` tokens.
|
||||
*/
|
||||
export function applyResolvedTheme(theme: ResolvedTheme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
Reference in new issue
Block a user