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 */} {t("lab.loadTicket")} setTicket(e.target.value)} placeholder={t("lab.loadTicketPh")} /> {t("lab.load")} {loadMsg && {loadMsg}} {/* Hypothetical session inputs */} {t("lab.tariffVersion")} setVersionId(e.target.value)}> {t("lab.activeVersion")} {state?.versions.map((v) => ( {new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)} ))} {t("lab.entered")} setEntered(e.target.value)} /> {t("lab.asOf")} setAsOf(e.target.value)} /> setAsOf(nowLocal())}> {t("lab.now")} {t("lab.category")} setCategory(e.target.value)} placeholder={t("lab.categoryPh")} /> {t("lab.payment")} setPaid(e.target.checked)} /> {t("lab.paid")} {paid && ( <> setPaidAt(e.target.value)} /> {t("lab.graceMin")} setGraceMin(e.target.value)} /> > )} {busy ? t("lab.pricing") : t("lab.price")} {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`; }
{t("lab.intro")}
{t("lab.curveHint")}