import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { fetchTariff, loadSimSession, simulateTariff, type SimulateResult, type SimPayment, type TariffState, } from "./api.js"; import { formatMoney, formatDuration } from "./lib/format.js"; // The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight // windows, daily caps, overstay) in seconds instead of waiting hours, against ANY // published tariff version, with no real ledger writes. Build a hypothetical session // (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any // instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and // the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md. /** 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()); } export function TariffLab() { const { t } = useTranslation(); const [state, setState] = useState(null); const [err, setErr] = useState(null); // Inputs (datetime-local strings, local wall-clock). const [entered, setEntered] = useState(() => { const d = new Date(); d.setHours(d.getHours() - 3); // default: a 3h-ago entry return toLocalInput(d.toISOString()); }); const [asOf, setAsOf] = useState(nowLocal); const [category, setCategory] = useState(""); const [versionId, setVersionId] = useState(""); // "" = active // Optional single hypothetical payment (the latest grants the walk-back grace). const [paid, setPaid] = useState(false); const [paidAt, setPaidAt] = useState(nowLocal); const [graceMin, setGraceMin] = useState("5"); // Load-a-real-ticket. const [ticket, setTicket] = useState(""); const [loadMsg, setLoadMsg] = useState(null); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { fetchTariff() .then(setState) .catch((e) => setErr((e as Error).message)); }, []); async function run() { setErr(null); setBusy(true); try { const payments: SimPayment[] = paid ? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }] : []; const r = await simulateTariff({ enteredAt: fromLocalInput(entered), asOf: fromLocalInput(asOf), payments, category: category.trim() || undefined, tariffVersionId: versionId || undefined, }); setResult(r); } catch (e) { setErr((e as Error).message); setResult(null); } finally { setBusy(false); } } async function loadTicket() { setLoadMsg(null); setErr(null); try { const s = await loadSimSession(ticket.trim()); setEntered(toLocalInput(s.enteredAt)); setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal()); setCategory(s.category ?? ""); setVersionId(s.tariffVersionId ?? ""); const last = s.payments.at(-1); if (last) { setPaid(true); setPaidAt(toLocalInput(last.paidAt)); setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : ""); } else { setPaid(false); } setLoadMsg(t("lab.loaded", { id: s.identity })); } catch (e) { setErr((e as Error).message); } } const currency = result?.currency ?? state?.active?.currency ?? "ALL"; return (

{t("lab.title")}

{t("lab.intro")}

{/* Load a real ticket */}
setTicket(e.target.value)} placeholder={t("lab.loadTicketPh")} />
{loadMsg && {loadMsg}}
{/* Hypothetical session inputs */}
setEntered(e.target.value)} /> setAsOf(e.target.value)} /> setCategory(e.target.value)} placeholder={t("lab.categoryPh")} /> {paid && ( <> setPaidAt(e.target.value)} /> {t("lab.graceMin")} setGraceMin(e.target.value)} /> )}
{err && {err}}
{result && (
{/* Outcome */}

{t("lab.outcome")}

{t("lab.amountDue")}
{formatMoney(result.pricing.amountMinor, currency)}
{t("lab.billedPeriod")}
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))} {result.pricing.overstay && ( {t("lab.overstay")} )} {result.pricing.withinGrace && ( {t("lab.settled")} )}
{t("lab.periodStart")}
{new Date(result.pricing.periodStart).toLocaleString()}
{result.pricing.graceExpiresAt && ( <>
{t("lab.graceExpires")}
{new Date(result.pricing.graceExpiresAt).toLocaleString()}
)}
{/* Duration curve from entry — see where the cap flattens / windows shift. */}

{t("lab.curve")}

{t("lab.curveHint")}

{result.curve.map((c) => ( ))}
{labelMin(c.minutes)} {formatMoney(c.amountMinor, currency)}
)}
); } function labelMin(min: number): string { if (min < 60) return `${min}m`; if (min < 1440) return `${min / 60}h`; return `${min / 1440}d`; }