diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index 8a76f8a..4d29f16 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -15,8 +15,13 @@ import { // usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. // Editable form mirror of TariffStructure, but money in major-unit strings. +// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the +// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes. +// The LAST block is always open-ended ("thereafter"): its hours field is unused +// and it has no bound. On submit, per-block hours accumulate into the engine's +// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null. interface BlockForm { - uptoMin: string; // "" = open-ended (last block) + hours: string; // duration of THIS band, in hours (ignored for the last block) price: string; // major units, e.g. "2.00" } interface FormState { @@ -40,10 +45,25 @@ function emptyForm(): FormState { dailyCap: "", lostTicket: "20.00", gracePeriodExitMin: "15", - blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }], + blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], }; } +// Convert a published structure's cumulative `uptoMin` (minutes) back into the +// per-band hours the form edits. Each band's hours = (its bound − previous bound) +// / 60; the open-ended last band has no hours. Legacy versions whose last block is +// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded +// tail simply shows as its own band and the operator adds/keeps an open-ended one. +function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] { + let prev = 0; + return blocks.map((b) => { + if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) }; + const hours = (b.uptoMin - prev) / 60; + prev = b.uptoMin; + return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) }; + }); +} + function formFromActive(s: TariffState): FormState { const v = s.active; if (!v) return emptyForm(); @@ -55,18 +75,22 @@ function formFromActive(s: TariffState): FormState { dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor), lostTicket: toMajor(st.lostTicketMinor), gracePeriodExitMin: String(st.gracePeriodExitMin), - blocks: st.blocks.map((b) => ({ - uptoMin: b.uptoMin == null ? "" : String(b.uptoMin), - price: toMajor(b.priceMinorPerIncrement), - })), + blocks: blocksToForm(st.blocks), }; } function toStructure(f: FormState): TariffStructure { - const blocks: TariffBlock[] = f.blocks.map((b) => ({ - uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)), - priceMinorPerIncrement: toMinor(b.price), - })); + // Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin` + // (minutes). The LAST band is always open-ended (uptoMin null) — its hours are + // ignored — so the published structure always satisfies the "last block must be + // open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md. + const last = f.blocks.length - 1; + let cumulativeMin = 0; + const blocks: TariffBlock[] = f.blocks.map((b, i) => { + if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; + cumulativeMin += Math.round(Number(b.hours || "0") * 60); + return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) }; + }); return { gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), incrementMin: Math.round(Number(f.incrementMin)), @@ -100,11 +124,23 @@ export function TariffComposer() { function setBlock(i: number, patch: Partial) { setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); } + // Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the + // last block always stays open-ended. function addBlock() { - setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] })); + setForm((f) => { + const tailIdx = f.blocks.length - 1; + const next = [...f.blocks]; + next.splice(tailIdx, 0, { hours: "1", price: "0.00" }); + return { ...f, blocks: next }; + }); } + // Remove a bounded band. The open-ended tail (last row) can't be removed (it's the + // required thereafter-rate); the guard also keeps at least the tail present. function removeBlock(i: number) { - setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) })); + setForm((f) => { + if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f; + return { ...f, blocks: f.blocks.filter((_, j) => j !== i) }; + }); } async function publish() { @@ -160,32 +196,44 @@ export function TariffComposer() { - + - {form.blocks.map((b, i) => ( - - - - - - ))} + {form.blocks.map((b, i) => { + const isTail = i === form.blocks.length - 1; + return ( + + + + + + ); + })}
{t("tariff.upToMin")}{t("tariff.bandDuration")} {t("tariff.pricePerIncrement")}
- setBlock(i, { uptoMin: e.target.value })} - placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")} - style={{ width: 110 }} - /> - - setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> - - -
+ {isTail ? ( + {t("tariff.thereafter")} + ) : ( + + setBlock(i, { hours: e.target.value })} + placeholder={t("tariff.egHours")} + style={{ width: 70 }} + /> + {t("tariff.hoursUnit")} + + )} + + setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> + + {!isTail && ( + + )} +