Files
parking_solution/apps/web/src/TariffLab.tsx
T
julian 7649b897c4 feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00

505 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createTariffDraft,
deleteTariffDraft,
fetchTariff,
fetchTariffDrafts,
publishTariffVersion,
simulateTariff,
updateTariffDraft,
type SimulateResult,
type TariffDraft,
type TariffState,
} from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { Modal } from "./ui/Modal.js";
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
import type { FeeBreakdown } from "@parking/shared";
import type { TFunction } from "i18next";
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
// live in their own mutable table (tariff_drafts), so experimenting never churns the
// immutable published versions or risks a half-baked card going live: the admin
// composes a draft in the modal (the same form the composer page uses), simulates
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
// `priceSession` the booth uses (server-side), so the lab and the live booth can
// never diverge. No ledger writes. See wiki/concepts/tariff.md.
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */
function fromLocalInput(v: string): string {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
}
function nowLocal(): string {
return toLocalInput(new Date().toISOString());
}
/** What the simulation runs against: the live card, a historical published
* version, or one lab draft. */
type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
/** Modal state: a draft being composed (id null = not yet saved). */
interface DraftEdit {
id: string | null;
name: string;
form: FormState;
}
export function TariffLab() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
const [selected, setSelected] = useState<Selection>({ kind: "active" });
const [err, setErr] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// The hypothetical stay: entry + exit, nothing else.
const [entered, setEntered] = useState<string>(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
const [exit, setExit] = useState<string>(nowLocal);
const [result, setResult] = useState<SimulateResult | null>(null);
const [busy, setBusy] = useState(false);
// The draft-composer modal.
const [edit, setEdit] = useState<DraftEdit | null>(null);
const [saving, setSaving] = useState(false);
const [editErr, setEditErr] = useState<string | null>(null);
async function refresh() {
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
setState(s);
setDrafts(d.drafts);
return d.drafts;
}
useEffect(() => {
refresh().catch((e) => setErr((e as Error).message));
}, []);
const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
const selectedVersion =
selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
function select(sel: Selection) {
setSelected(sel);
setResult(null); // a stale price against another card would mislead
setErr(null);
setNotice(null);
}
async function run() {
setErr(null);
setBusy(true);
try {
const r = await simulateTariff({
enteredAt: fromLocalInput(entered),
asOf: fromLocalInput(exit),
// A draft carries its own structure+currency; a historical version is
// referenced by id; otherwise the ACTIVE version.
...(selectedDraft
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
: selectedVersion
? { tariffVersionId: selectedVersion.id }
: {}),
});
setResult(r);
} catch (e) {
setErr((e as Error).message);
setResult(null);
} finally {
setBusy(false);
}
}
// --- draft actions ---
function newDraft() {
// Start from the live card when there is one — the admin usually experiments
// with a variation of today's prices, not from a blank slate.
const form = state?.active ? formFromActive(state) : emptyForm();
setEditErr(null);
setEdit({ id: null, name: "", form });
}
function editDraft(d: TariffDraft) {
setEditErr(null);
setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
}
async function saveDraft() {
if (!edit) return;
setSaving(true);
setEditErr(null);
try {
const body = {
name: edit.name.trim(),
currency: edit.form.currency.trim().toUpperCase(),
structure: toStructure(edit.form),
};
const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
await refresh();
setEdit(null);
select({ kind: "draft", id: saved.id });
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setEditErr(text);
} finally {
setSaving(false);
}
}
async function removeDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
setErr(null);
try {
await deleteTariffDraft(d.id);
await refresh();
select({ kind: "active" });
} catch (e) {
setErr((e as Error).message);
}
}
async function publishDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
setErr(null);
setNotice(null);
try {
// The draft's name rides along onto the immutable version.
await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
await refresh();
setNotice(t("tariff.publishedOk"));
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setErr(text);
}
}
const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p>
<div className="flex flex-col gap-4 lg:flex-row">
{/* Main: the hypothetical stay + result, priced against the selection. */}
<div className="min-w-0 flex-1">
{/* What we're pricing against + draft actions. */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
{selectedDraft
? selectedDraft.name
: selectedVersion
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
: t("lab.activeTariff")}
</span>
{selectedDraft && (
<>
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
{t("lab.edit")}
</button>
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
{t("lab.publish")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
{t("lab.delete")}
</button>
</>
)}
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
</div>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.exit")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
{t("lab.now")}
</button>
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
</>
)}
</dl>
{/* HOW the sum is produced — line items from the SAME engine walk
(their sum is the amount by construction). */}
{result.breakdown && (
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
)}
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Sidebar: lab drafts + the full published history; click any to price
against it. */}
<aside className="w-full shrink-0 lg:w-72">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
<button type="button" className="btn btn-sm" onClick={newDraft}>
{t("lab.newDraft")}
</button>
</div>
<ul className="flex flex-col gap-1">
{drafts.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => select({ kind: "draft", id: d.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "draft" && selected.id === d.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {formatDateTime(d.updatedAt, t)}
</span>
</button>
</li>
))}
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
</ul>
{/* Published versions: the active card first, then the immutable history
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("lab.published")}
</h3>
<ul className="flex flex-col gap-1">
<li>
<button
type="button"
onClick={() => select({ kind: "active" })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "active"
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{t("lab.activeTariff")}
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
</span>
</button>
</li>
{state?.versions
.filter((v) => v.id !== state.active?.id)
.map((v) => (
<li key={v.id}>
<button
type="button"
onClick={() => select({ kind: "version", id: v.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "version" && selected.id === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{v.name ?? formatDateTime(v.effectiveFrom, t)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
</li>
))}
</ul>
</aside>
</div>
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
<Modal
open={edit != null}
onClose={() => setEdit(null)}
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
width="max-w-3xl"
>
{edit && (
<div>
<div className="mb-4 flex items-center gap-2">
<label className="label">{t("lab.draftName")}</label>
<input
className="input w-72"
value={edit.name}
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
placeholder={t("lab.draftNamePh")}
/>
</div>
<TariffEditorForm
form={edit.form}
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
/>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
</button>
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
</div>
</div>
)}
</Modal>
</section>
);
}
function labelMin(min: number): string {
if (min < 60) return `${min}m`;
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
/** The fee's line items — every row states its time window / rule and its amount, so
* the operator can retrace the exact sum (caps show as negative adjustments). */
function BreakdownTable({
b,
periodStart,
currency,
t,
}: {
b: FeeBreakdown;
periodStart: string;
currency: string;
t: TFunction;
}) {
const startMs = Date.parse(periodStart);
const multiDay = b.billedMinutes > 1440;
const at = (min: number) => {
const iso = new Date(startMs + min * 60_000).toISOString();
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
};
const money = (m: number) => formatMoney(m, currency);
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
return (
<div className="mt-3 border-t border-term-border pt-2">
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
{b.billedMinutes > 0 && (
<p className="hint mb-1.5">
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
</p>
)}
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{b.items.map((it, i) => {
let label: string;
let amount: number;
let cls = "text-term-text";
switch (it.kind) {
case "grace":
label = t("lab.bd.grace", { min: it.minutes });
amount = 0;
cls = "text-term-green";
break;
case "band":
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
amount = it.amountMinor;
break;
case "package":
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
amount = it.amountMinor;
break;
case "step":
label = it.repeated
? t("lab.bd.stepRepeated", { day: it.day })
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
amount = it.amountMinor;
break;
case "cap":
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
amount = it.amountMinor;
cls = "text-term-red";
break;
}
return (
<tr key={i} className="border-b border-term-border/40">
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
</tr>
);
})}
<tr>
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
</tr>
</tbody>
</table>
</div>
);
}