From d9e6c1383125a46fac3fae36443088d7d01d148f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 14:31:25 +0200 Subject: [PATCH 1/6] feat(tariff): whole-window package pricing mode (packageMinor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A windowed card can now charge ONE total for any presence in its window — the real night rate ("20:00–07:00 = 400, leave earlier and it's still 400"), which the per-increment flatMinor could not express (park-buzi's "night 400" card billed 400/HOUR). Engine charges once per contiguous run of increments the card wins, tracked across rolling-day segments so a night crossing the 24h boundary charges once; out-of-window increments price by the base card as usual. Operator decisions (2026-07-05): per-occurrence repeat (two nights = two charges), any-touch-pays-full, windowed cards only (a base "price per day" is a 1-row up-to table). Validator: mutually exclusive with flat/blocks/steps, no per-card cap, forbidden on the defaultCard. flatMinor docs clarified as PER INCREMENT. 6 new engine tests. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- packages/shared/src/index.ts | 47 ++++++++++++++++++++----- packages/shared/src/tariff.test.ts | 56 +++++++++++++++++++++++++++++- wiki/concepts/tariff-time-tiers.md | 19 ++++++++-- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e925c4d..a1092bb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -606,8 +606,9 @@ export interface TariffWindow { readonly toHour?: string; } -/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap). - * `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */ +/** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table + * (defaultCard only), or a whole-window package (windowed cards only). The pricing + * fields are mutually exclusive — exactly one. The defaultCard has no window. */ export interface TariffCard { /** Human label (also the final, deterministic precedence tiebreak). */ readonly name: string; @@ -617,13 +618,21 @@ export interface TariffCard { readonly category?: string; /** Wall-clock activation window. Absent only on the defaultCard (always active). */ readonly window?: TariffWindow; - /** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */ + /** Flat price PER BILLING INCREMENT (an hourly flat rate at increment 60) — + * mutually exclusive with the other pricing fields. NOT a whole-stay price; + * for "one total for the whole window" use `packageMinor`. */ readonly flatMinor?: number; - /** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */ + /** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */ readonly blocks?: readonly TariffBlock[]; - /** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/ - * `blocks`). The top tier's total is this card's per-day price. */ + /** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other + * pricing fields; defaultCard only). The top tier's total is the per-day price. */ readonly steps?: readonly TariffStep[]; + /** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per + * contiguous occurrence of this card winning increments — e.g. "any presence in + * the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of + * the window pays the full package; a stay spanning two nights pays it twice + * (once per occurrence). Mutually exclusive with the other pricing fields. */ + readonly packageMinor?: number; /** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs * a mixed day (see computeFeeV2). null = no cap. */ readonly dailyCapMinor?: number | null; @@ -856,18 +865,30 @@ function computeFeeV2( if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!); let total = 0; + // WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per + // contiguous run of increments it wins (an "occurrence" — e.g. one night), however + // little of the window the car actually used. The tracker survives the day-segment + // loop so a night run crossing the rolling-24h boundary charges once, not twice; + // the charge lands in the segment where the occurrence starts (that day's cap + // applies to it). A stay touching the window on two different nights = two + // occurrences = two charges. + let prevWinner: TariffCard | null = null; for (let segStart = 0; segStart < minutes; segStart += DAY) { const segEnd = Math.min(segStart + DAY, minutes); let segFee = 0; for (let within = segStart; within < segEnd; within += inc) { const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz); const card = selectCard(cards, wall); - if (card.flatMinor != null) { + if (card.packageMinor != null) { + // First increment of a new occurrence pays the package; the rest ride free. + if (prevWinner !== card) segFee += card.packageMinor; + } else if (card.flatMinor != null) { segFee += card.flatMinor; } else { // Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule). segFee += rateAt(card.blocks ?? [], within - segStart); } + prevWinner = card; } if (dayCap != null) segFee = Math.min(segFee, dayCap); total += segFee; @@ -983,9 +1004,17 @@ function validateCard(c: Partial | undefined, label: string, isDefau const hasFlat = c.flatMinor != null; const hasBlocks = c.blocks != null; const hasStepTable = c.steps != null; - const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length; + const hasPackage = c.packageMinor != null; + const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length; if (modes !== 1) { - errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`); + errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`); + } else if (hasPackage) { + // A whole-window package needs a window to be an occurrence of — meaningless on + // the always-active defaultCard (a base "one price per stay/day" is a 1-row + // stepped table there). See wiki/concepts/tariff-time-tiers.md. + if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`); + nonNegInt(c.packageMinor, `${label}.packageMinor`, errs); + if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`); } else if (hasFlat) { nonNegInt(c.flatMinor, `${label}.flatMinor`, errs); if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`); diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts index 6a758fd..3cc7de3 100644 --- a/packages/shared/src/tariff.test.ts +++ b/packages/shared/src/tariff.test.ts @@ -223,7 +223,7 @@ describe("validate V2", () => { }); it("rejects a card with both flat and blocks", () => { const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } }); - expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps"); + expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, steps, or packageMinor"); }); it("rejects defaultCard with a window", () => { const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } }); @@ -383,3 +383,57 @@ describe("stepped (up-to) pricing — owner matrix", () => { expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// (j) WINDOW PACKAGE (packageMinor) — "any presence in the window = one total". +// Charged once per contiguous occurrence of the card winning increments; any touch +// pays the full package; a run crossing the rolling-24h boundary charges ONCE. +// Base: open-ended 100/h ladder (minor 10000). Night card: 20:00–07:00 = 40000. +// tz Europe/Tirane (summer = UTC+2); windows carry no dow so weekday is irrelevant. +// --------------------------------------------------------------------------- +describe("V2 window package (whole-window total)", () => { + const pkg: TariffStructureV2 = { + version: 2, + tz: "Europe/Tirane", + gracePeriodEntryMin: 0, + incrementMin: 60, + lostTicketMinor: 0, + gracePeriodExitMin: 5, + overstay: "reprice", + defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null }, + windowedCards: [{ name: "night", priority: 10, packageMinor: 40000, window: { fromHour: "20:00", toHour: "07:00" } }], + }; + const fee = (enter: string, exit: string) => computeFee(enter, exit, pkg); + + it("leave early, the package stays: 22:00→23:30 (90 min in-window) = 40000", () => { + expect(fee("2026-06-16T22:00:00+02:00", "2026-06-16T23:30:00+02:00")).toBe(40000); + }); + + it("any touch pays full: 06:00→06:45 (45 min at the window's tail) = 40000", () => { + expect(fee("2026-06-16T06:00:00+02:00", "2026-06-16T06:45:00+02:00")).toBe(40000); + }); + + it("increments inside one occurrence add nothing: a full night 20:00→07:00 = 40000", () => { + expect(fee("2026-06-16T20:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(40000); + }); + + it("mixed 29h stay: two occurrences + day hours; the run over the rolling-day boundary charges ONCE", () => { + // Enter Tue 02:00, exit Wed 07:00 (29 increments). Occurrence 1: 02:00–06:00 (the + // overnight window's tail) = 40000. Base: 07:00–19:00 = 13 × 10000. Occurrence 2: + // Tue 20:00 → Wed 06:00 — CROSSES the rolling-24h boundary (Wed 02:00) but is one + // contiguous run → one 40000, not two. Total 40000 + 130000 + 40000 = 210000. + expect(fee("2026-06-16T02:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(210000); + }); + + it("validates: package on the defaultCard is rejected", () => { + const bad = { ...pkg, defaultCard: { name: "d", priority: 0, packageMinor: 40000 } }; + expect(validateTariffStructure(bad).some((e) => /only allowed on a windowed card/.test(e))).toBe(true); + }); + + it("validates: package is exclusive with other pricing fields + the cap", () => { + const both = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, flatMinor: 1, window: { dow: [1] } }] }; + expect(validateTariffStructure(both).some((e) => /exactly one of/.test(e))).toBe(true); + const capped = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, dailyCapMinor: 100, window: { dow: [1] } }] }; + expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true); + }); +}); diff --git a/wiki/concepts/tariff-time-tiers.md b/wiki/concepts/tariff-time-tiers.md index a32e2b5..18f267c 100644 --- a/wiki/concepts/tariff-time-tiers.md +++ b/wiki/concepts/tariff-time-tiers.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, business, pricing, design] sources: [parksql2017-legacy-schema] -updated: 2026-06-18 +updated: 2026-07-05 status: settled --- @@ -140,7 +140,22 @@ case stays one rate card; tiers are opt-in. `DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future seam, mirroring per-relay direction. - **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1 - is published as a single open-ended block (V1 has no flat field). + is published as a single open-ended block (V1 has no flat field). ⚠ `flatMinor` is **per billing + increment** (an hourly flat rate at increment 60) — NOT a whole-stay/whole-window price. This was + misread in the field (park-buzi published a "night 400" believing it covered the night; it billed + 400/h, 2026-07-05) — the UI now labels it "Flat price / hour" and the whole-window need got its own + mode: +- **WINDOW PACKAGE (`packageMinor`, 2026-07-05 — windowed cards only).** "Any presence in this + window = ONE total" (the real night rate: 20:00–07:00 = 400, leave earlier and it's still 400). + Decisions (operator, 2026-07-05): charged **once per occurrence** (a stay touching two nights pays + twice); **any touch pays full** (an 06:30 arrival before the 07:00 close pays the whole package — + package pricing's accepted sharp edge); **not offered on the base card** (a base "one price per + day" is a 1-row up-to table — no duplicate concept). Engine: one charge per **contiguous run of + increments the card wins**, tracked across rolling-day segments so a night crossing the 24h + boundary charges once; out-of-window increments price by the base rate as usual; the charge lands + in the day segment where the occurrence starts (that day's default-card cap applies). Mutually + exclusive with flat/blocks/steps + no per-card cap (the package IS the window's total); validator + enforces both and the composer offers the mode only on tier cards. - **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes, optional date range, optional hour range with an overnight hint; category; priority; flat/ladder From 52a89bfa569267532af4ab6a88a19d6fd7798ea0 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 14:31:32 +0200 Subject: [PATCH 2/6] feat(web): move tariff lab under /setup/tariff as a sub-tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a tool that tests the rate card. /setup/tariff is now a small layout with two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the original /setup/tariff-lab) redirect, and the tariff-read-only redirect branch on /subscriptions is gone with the tab. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/src/router.tsx | 50 +++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 384d1b0..24728ef 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -117,9 +117,10 @@ function SetupLayout() { } /** Subscriptions layout — a standalone top-level section (its own header nav entry), - * with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each - * tab is a gated child route; an operator with only subscription:read sees just the - * first tab. */ + * with tabs for the subscriber catalog and the plan catalog. Each tab is a gated + * child route; an operator with only subscription:read sees just the first tab. + * (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so + * it lives with the tariff.) */ function SubscriptionsLayout() { const { user } = rootRoute.useRouteContext(); const { t } = useTranslation(); @@ -129,7 +130,21 @@ function SubscriptionsLayout() { + + + ); +} + +/** Tariff layout — the rate-card hub under Setup: the composer (index) and the + * pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */ +function TariffLayout() { + const { t } = useTranslation(); + return ( +
+
@@ -519,7 +534,10 @@ const legacyRedirects = ( ["/shift", "/shifts"], ["/setup/subscriptions", "/subscriptions"], ["/setup/plans", "/subscriptions/plans"], - ["/setup/tariff-lab", "/subscriptions/tariff-lab"], + // The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab + // (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests). + ["/setup/tariff-lab", "/setup/tariff/lab"], + ["/subscriptions/tariff-lab", "/setup/tariff/lab"], ["/setup/shifts", "/shifts"], ["/setup/reports", "/reports"], ] as const @@ -633,8 +651,20 @@ const tariffRoute = createRoute({ getParentRoute: () => setupRoute, path: "tariff", beforeLoad: ({ context }) => requirePerm("tariff:read")(context), + component: TariffLayout, +}); +const tariffComposerRoute = createRoute({ + getParentRoute: () => tariffRoute, + path: "/", component: () => , }); +// The tariff LAB — lives with the tariff it tests (moved from /subscriptions, +// 2026-07-05). The parent's tariff:read gate covers it. +const tariffLabRoute = createRoute({ + getParentRoute: () => tariffRoute, + path: "lab", + component: () => , +}); // --- /subscriptions — a standalone top-level section with its own tabs. The catalog // (index), the plan catalog, and the tariff lab live here, not under /setup. --- @@ -651,7 +681,6 @@ const subscriptionsIndexRoute = createRoute({ beforeLoad: ({ context }) => { if (can(context.user, "subscription:read")) return; if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" }); - if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" }); throw redirect({ to: "/booth" }); }, component: function SubscriptionsRoute() { @@ -665,12 +694,6 @@ const subscriptionPlansRoute = createRoute({ beforeLoad: ({ context }) => requirePerm("subscription:plan")(context), component: () => , }); -const tariffLabRoute = createRoute({ - getParentRoute: () => subscriptionsRoute, - path: "tariff-lab", - beforeLoad: ({ context }) => requirePerm("tariff:read")(context), - component: () => , -}); const siteRoute = createRoute({ getParentRoute: () => setupRoute, path: "site", @@ -751,11 +774,10 @@ const routeTree = rootRoute.addChildren([ subscriptionsRoute.addChildren([ subscriptionsIndexRoute, subscriptionPlansRoute, - tariffLabRoute, ]), setupRoute.addChildren([ setupDevicesRoute, - tariffRoute, + tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]), siteRoute, usersRoute, rolesRoute, From fd9885e9ec0e43f9dcf9c654541df3682fb724f1 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 14:31:42 +0200 Subject: [PATCH 3/6] feat(tariff-lab): DB-backed draft tariffs + named published versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experimenting used to mean publishing — churning the immutable version history and risking real tickets pricing against a half-baked card while the admin iterated. The lab is now a true sandbox: - tariff_drafts table (migration 0021): MUTABLE by design — the one exception to "editing publishes a version"; a draft prices nothing and signs nothing. Drafts are validated + tz-stamped on save exactly like a publish, so a saved draft always simulates and never fails at publish. - CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update); publishing a draft goes through the normal immutable POST /api/tariff/versions path. - Lab UI rebuilt: sidebar lists lab drafts AND the full published history (click any to price against it); main pane cut to pure entry/exit (ticket loader, payment, category inputs dropped); the composer form is extracted to TariffEditorForm.tsx and reused in a modal (new drafts prefill from the active card); per-draft Publish with confirm. - tariff_versions.name (migration 0022): optional label stamped at publish — carried from the lab draft, or typed in the composer's new optional field — so history reads "Winter 2027", not UUID prefixes. - Includes the composer UI + sq/en labels for the package mode (engine landed in d9e6c13) and the "Flat price / hour" relabel. 5 new server integration tests (RBAC, roundtrip, validation, tz-stamp + simulate + publish w/ name); server suite 288 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/tariff-drafts.test.ts | 177 +++++ apps/server/src/routes/tariffs.ts | 107 +++- apps/web/src/TariffComposer.tsx | 580 +---------------- apps/web/src/TariffEditorForm.tsx | 604 ++++++++++++++++++ apps/web/src/TariffLab.tsx | 494 +++++++++----- apps/web/src/api.ts | 49 +- apps/web/src/lib/i18n/en.ts | 42 +- apps/web/src/lib/i18n/sq.ts | 42 +- packages/db/drizzle/0021_tariff_drafts.sql | 14 + .../db/drizzle/0022_tariff_version_name.sql | 6 + packages/db/drizzle/meta/_journal.json | 14 + packages/db/src/schema.ts | 28 + wiki/concepts/tariff.md | 44 +- wiki/log.md | 38 ++ 14 files changed, 1457 insertions(+), 782 deletions(-) create mode 100644 apps/server/src/routes/tariff-drafts.test.ts create mode 100644 apps/web/src/TariffEditorForm.tsx create mode 100644 packages/db/drizzle/0021_tariff_drafts.sql create mode 100644 packages/db/drizzle/0022_tariff_version_name.sql diff --git a/apps/server/src/routes/tariff-drafts.test.ts b/apps/server/src/routes/tariff-drafts.test.ts new file mode 100644 index 0000000..b55171d --- /dev/null +++ b/apps/server/src/routes/tariff-drafts.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestDb } from "@parking/db/testing"; +import { type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; + +// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable +// published versions. The contract under test: drafts are validated + tz-stamped on +// save exactly like a publish (so "publish this draft" can never fail on a card that +// saved fine), mutations need tariff:update, and publishing a draft goes through the +// normal immutable-version path untouched. + +let db: Db; +let close: () => void; +let app: FastifyInstance; + +beforeEach(async () => { + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + close(); +}); + +const V1_STRUCTURE = { + gracePeriodEntryMin: 5, + incrementMin: 60, + lostTicketMinor: 2000, + gracePeriodExitMin: 10, + overstay: "reprice", + blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], + dailyCapMinor: null, +}; + +// A V2 card with a night package — tz left blank on purpose: the server must stamp it. +const V2_STRUCTURE = { + version: 2, + tz: "", + gracePeriodEntryMin: 5, + incrementMin: 60, + lostTicketMinor: 2000, + gracePeriodExitMin: 10, + overstay: "reprice", + defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null }, + windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }], +}; + +async function editor() { + const { username, password } = await seedUser(db, { + username: "editor", + roleId: "editor", + permissions: ["tariff:read", "tariff:update"], + }); + return login(app, username, password); +} + +describe("tariff drafts", () => { + it("requires auth", async () => { + const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" }); + expect(res.statusCode).toBe(401); + }); + + it("a tariff:read-only user can list but not create", async () => { + const { username, password } = await seedUser(db, { + username: "viewer", + roleId: "viewer", + permissions: ["tariff:read"], + }); + const { cookie, csrf } = await login(app, username, password); + + const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(list.statusCode).toBe(200); + expect(list.json().drafts).toEqual([]); + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers: { cookie, "x-csrf-token": csrf }, + payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE }, + }); + expect(create.statusCode).toBe(403); + }); + + it("create → list → update → delete roundtrip", async () => { + const { cookie, csrf } = await editor(); + const headers = { cookie, "x-csrf-token": csrf }; + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers, + payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE }, + }); + expect(create.statusCode).toBe(201); + const draft = create.json(); + expect(draft.name).toBe("Winter proposal"); + expect(draft.currency).toBe("ALL"); // normalised to upper case + expect(draft.createdBy).toBe("editor"); + + const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(list.json().drafts).toHaveLength(1); + + const update = await app.inject({ + method: "PUT", + url: `/api/tariff/drafts/${draft.id}`, + headers, + payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE }, + }); + expect(update.statusCode).toBe(200); + expect(update.json().name).toBe("Winter v2"); + + const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers }); + expect(del.statusCode).toBe(204); + const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(after.json().drafts).toEqual([]); + }); + + it("rejects an invalid structure with problems (validated like a publish)", async () => { + const { cookie, csrf } = await editor(); + const res = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers: { cookie, "x-csrf-token": csrf }, + payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().problems?.length).toBeGreaterThan(0); + }); + + it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => { + const { cookie, csrf } = await editor(); + const headers = { cookie, "x-csrf-token": csrf }; + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers, + payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE }, + }); + expect(create.statusCode).toBe(201); + const draft = create.json(); + expect(draft.structure.tz).toBe("Europe/Tirane"); + + // The lab prices the draft by sending its stored structure inline. + const sim = await app.inject({ + method: "POST", + url: "/api/tariff/simulate", + headers, + payload: { + enteredAt: "2026-07-03T21:00:00.000+02:00", + asOf: "2026-07-03T23:00:00.000+02:00", + structure: draft.structure, + currency: draft.currency, + }, + }); + expect(sim.statusCode).toBe(200); + expect(sim.json().pricing.amountMinor).toBe(40000); // one night package + + // "Publish this draft" = the normal immutable-version path with the draft's card; + // the draft's name rides along as the version's optional label. + const publish = await app.inject({ + method: "POST", + url: "/api/tariff/versions", + headers, + payload: { currency: draft.currency, structure: draft.structure, name: draft.name }, + }); + expect(publish.statusCode).toBe(201); + const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } }); + expect(state.json().active?.name).toBe("Night package"); + expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000); + }); +}); diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index bf789a0..30ac935 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; +import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, isTariffV2, @@ -25,10 +25,19 @@ interface PublishBody { structure: TariffStructure; /** When this version takes effect (ISO-8601). Defaults to now. */ effectiveFrom?: string; + /** Optional human label (e.g. carried from the lab draft being published). */ + name?: string; } const SITE_TARIFF_NAME = "Site tariff"; +/** Body for saving a lab draft (create + update share the shape). */ +interface DraftBody { + name: string; + currency: string; + structure: TariffStructure; +} + /** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write. * Provide a structure source (one of): `tariffVersionId`, inline `structure`, or * neither (uses the active version). */ @@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise "/api/tariff/versions", { preHandler: writeGuard }, async (req, reply) => { - const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody); + const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody); if (!currency || typeof currency !== "string" || currency.length < 3) { return reply.code(400).send({ error: "currency (ISO 4217) required" }); } // For a windowed (V2) structure, stamp the wall-clock timezone from SITE config // (not the client) BEFORE validating — so the frozen tz is authoritative and the // validation that requires tz passes. A V1 (bare) structure is left untouched. - let toStore: TariffStructure = structure; - if (structure && isTariffV2(structure)) { - const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); - const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ; - toStore = { ...structure, tz }; - } + const toStore = stampSiteTz(structure); const problems = validateTariffStructure(toStore); if (problems.length) { @@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise const row = { id, tariffId, + name: typeof name === "string" && name.trim() ? name.trim() : null, effectiveFrom: effective, currency, structure: toStore as unknown as Record, @@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise }, ); + // --- Lab drafts --------------------------------------------------------------- + // The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the + // schema for why mutability is safe here — a draft prices nothing and signs + // nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the + // simulator can always price them and "publish this draft" can never surprise the + // admin with a card that saved fine but won't go live. Publishing a draft is just + // POST /api/tariff/versions with the draft's structure — same guard, same + // validation, same immutability. + app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => { + const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all(); + return { drafts }; + }); + + app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => { + const parsed = parseDraftBody(req.body); + if ("error" in parsed) return reply.code(400).send(parsed); + const now = new Date().toISOString(); + const row = { + id: randomUUID(), + name: parsed.name, + currency: parsed.currency, + structure: parsed.structure as unknown as Record, + createdBy: req.user?.username ?? null, + createdAt: now, + updatedAt: now, + }; + db.insert(tariffDrafts).values(row).run(); + return reply.code(201).send(row); + }); + + app.put<{ Params: { id: string }; Body: DraftBody }>( + "/api/tariff/drafts/:id", + { preHandler: writeGuard }, + async (req, reply) => { + const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); + if (!existing) return reply.code(404).send({ error: "draft not found" }); + const parsed = parseDraftBody(req.body); + if ("error" in parsed) return reply.code(400).send(parsed); + const patch = { + name: parsed.name, + currency: parsed.currency, + structure: parsed.structure as unknown as Record, + updatedAt: new Date().toISOString(), + }; + db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run(); + return { ...existing, ...patch }; + }, + ); + + app.delete<{ Params: { id: string } }>( + "/api/tariff/drafts/:id", + { preHandler: writeGuard }, + async (req, reply) => { + const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); + if (!existing) return reply.code(404).send({ error: "draft not found" }); + db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run(); + return reply.code(204).send(); + }, + ); + + /** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */ + function parseDraftBody( + body: DraftBody | undefined, + ): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } { + const b = body ?? ({} as DraftBody); + const name = (b.name ?? "").trim(); + if (!name) return { error: "name required" }; + const currency = (b.currency ?? "").trim().toUpperCase(); + if (currency.length < 3) return { error: "currency (ISO 4217) required" }; + const structure = stampSiteTz(b.structure); + const problems = validateTariffStructure(structure); + if (problems.length) return { error: "invalid tariff structure", problems }; + return { name, currency, structure }; + } + + /** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the + * client); a V1 (bare) structure passes through untouched. */ + function stampSiteTz(structure: TariffStructure): TariffStructure { + if (structure && isTariffV2(structure)) { + const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ; + return { ...structure, tz }; + } + return structure; + } + /** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */ function tariffVersionIdFor(whenIso: string): string | null { const tariffId = ensureSiteTariff(); diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index 2a1004e..7b24dce 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -1,267 +1,23 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - ApiError, - fetchTariff, - isTariffV2, - publishTariffVersion, - type TariffBlock, - type TariffCard, - type TariffStep, - type TariffStructure, - type TariffState, -} from "./api.js"; +import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js"; +import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js"; -// Tariff composer — the admin builds + edits the rate card at runtime. Publishing +// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing // creates a new IMMUTABLE version (the active card); old versions are kept so past -// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for -// 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 { - hours: string; // duration of THIS band, in hours (ignored for the last block) - price: string; // major units, e.g. "2.00" -} -// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the -// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md. -interface StepForm { - hours: string; // inclusive upper bound of this tier, in hours (e.g. "3") - total: string; // TOTAL major units for a stay within this tier (e.g. "5.00") -} -// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped -// (up-to) total-by-duration table. -interface PricingForm { - mode: "ladder" | "flat" | "stepped"; - flat: string; // major units (used when mode==="flat") - blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") - steps: StepForm[]; // up-to tiers (used when mode==="stepped") - dailyCap: string; // "" = no cap (ladder only) -} -// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. -interface TierForm { - name: string; - priority: string; - category: string; // "" = applies to all categories - dow: number[]; // selected days 0..6; empty = every day - fromHour: string; // "" = all day - toHour: string; - dateFrom: string; // "" = unbounded - dateTo: string; - pricing: PricingForm; -} -interface FormState { - currency: string; - gracePeriodEntryMin: string; - incrementMin: string; - lostTicket: string; - gracePeriodExitMin: string; - // The default (always-active) card — its own flat/ladder body + daily cap. - base: PricingForm; - // Optional time/category tiers. Empty ⇒ a bare V1 structure is published. - tiers: TierForm[]; -} - -const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); -const toMajor = (minor: number): string => (minor / 100).toFixed(2); - -function emptySteps(): StepForm[] { - return [ - { hours: "1", total: "2.00" }, - { hours: "3", total: "5.00" }, - ]; -} -function emptyLadder(): PricingForm { - return { - mode: "ladder", - flat: "0.00", - dailyCap: "", - blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], - steps: emptySteps(), - }; -} -function emptyTier(): TierForm { - return { - name: "", - priority: "10", - category: "", - dow: [], - fromHour: "", - toHour: "", - dateFrom: "", - dateTo: "", - pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, - }; -} - -function emptyForm(): FormState { - return { - currency: "EUR", - gracePeriodEntryMin: "15", - incrementMin: "60", - lostTicket: "20.00", - gracePeriodExitMin: "15", - base: emptyLadder(), - tiers: [], - }; -} - -// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band -// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still -// load (shown as their own band). -function blocksToForm(blocks: TariffBlock[]): 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) }; - }); -} - -// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. -function stepsToForm(steps: TariffStep[]): StepForm[] { - return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) })); -} - -// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped). -function pricingFromCard(c: { - flatMinor?: number; - blocks?: TariffBlock[]; - steps?: TariffStep[]; - dailyCapMinor?: number | null; -}): PricingForm { - if (c.steps != null && c.steps.length > 0) { - return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) }; - } - if (c.flatMinor != null) { - return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() }; - } - return { - mode: "ladder", - flat: "0.00", - dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), - blocks: blocksToForm(c.blocks ?? []), - steps: emptySteps(), - }; -} - -function tierFromCard(c: TariffCard): TierForm { - const w = c.window ?? {}; - return { - name: c.name, - priority: String(c.priority), - category: c.category ?? "", - dow: w.dow ? [...w.dow] : [], - fromHour: w.fromHour ?? "", - toHour: w.toHour ?? "", - dateFrom: w.dateFrom ?? "", - dateTo: w.dateTo ?? "", - pricing: pricingFromCard(c), - }; -} - -function formFromActive(s: TariffState): FormState { - const v = s.active; - if (!v) return emptyForm(); - const st = v.structure; - const common = { - currency: v.currency, - gracePeriodEntryMin: String(st.gracePeriodEntryMin), - incrementMin: String(st.incrementMin), - lostTicket: toMajor(st.lostTicketMinor), - gracePeriodExitMin: String(st.gracePeriodExitMin), - }; - if (isTariffV2(st)) { - return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) }; - } - // V1: the bare ladder becomes the default card body; no tiers. - return { ...common, base: pricingFromCard(st), tiers: [] }; -} - -// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm. -function pricingToCardBody(p: PricingForm): Pick { - if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; - if (p.mode === "stepped") { - // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours"). - const steps: TariffStep[] = p.steps.map((s) => ({ - uptoMin: Math.round(Number(s.hours || "0") * 60), - totalMinor: toMinor(s.total), - })); - return { steps }; - } - // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. - const last = p.blocks.length - 1; - let cum = 0; - const blocks: TariffBlock[] = p.blocks.map((b, i) => { - if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; - cum += Math.round(Number(b.hours || "0") * 60); - return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) }; - }); - return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) }; -} - -function tierToCard(tr: TierForm): TariffCard { - const window: TariffCard["window"] = {}; - if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b); - if (tr.fromHour && tr.toHour) { - window.fromHour = tr.fromHour; - window.toHour = tr.toHour; - } - if (tr.dateFrom) window.dateFrom = tr.dateFrom; - if (tr.dateTo) window.dateTo = tr.dateTo; - const card: TariffCard = { - name: tr.name.trim() || "tier", - priority: Math.round(Number(tr.priority || "0")), - ...pricingToCardBody(tr.pricing), - }; - if (tr.category.trim()) card.category = tr.category.trim(); - if (Object.keys(window).length > 0) card.window = window; - return card; -} - -function toStructure(f: FormState): TariffStructure { - const common = { - gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), - incrementMin: Math.round(Number(f.incrementMin)), - lostTicketMinor: toMinor(f.lostTicket), - gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), - overstay: "reprice" as const, - }; - const baseBody = pricingToCardBody(f.base); - - // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants - // tiers gets exactly today's shape; the server leaves it untouched). - if (f.tiers.length === 0) { - if (f.base.mode === "stepped") { - // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap). - return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null }; - } - if (f.base.mode === "flat") { - // A flat V1: a single open-ended block at the flat rate (V1 has no flat field). - return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; - } - return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null }; - } - - // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here). - return { - ...common, - version: 2, - tz: "", - defaultCard: { name: "default", priority: 0, ...baseBody }, - windowedCards: f.tiers.map(tierToCard), - }; -} +// sessions reprice correctly. The form machinery is shared with the Tariff Lab's +// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the +// lab (a draft only becomes real through this same publish path). See +// wiki/concepts/tariff.md. export function TariffComposer() { const { t } = useTranslation(); const [state, setState] = useState(null); const [form, setForm] = useState(emptyForm); + // Optional label for the version about to be published. Deliberately NOT prefilled + // from the active version — a tweaked card republished under last season's name + // would mislabel the history. + const [versionName, setVersionName] = useState(""); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); @@ -274,70 +30,18 @@ export function TariffComposer() { .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); }, []); - function set(key: K, value: FormState[K]) { - setForm((f) => ({ ...f, [key]: value })); - } - - // --- pricing-body editing (used by the default card AND each tier) --- - // `update` maps the old PricingForm to a new one; `target` selects which body: - // the base card, or tier index N. - function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) { - setForm((f) => { - if (target === "base") return { ...f, base: update(f.base) }; - return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) }; - }); - } - function setBlock(target: "base" | number, i: number, patch: Partial) { - updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); - } - // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended. - function addBlock(target: "base" | number) { - updatePricing(target, (p) => { - const next = [...p.blocks]; - next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" }); - return { ...p, blocks: next }; - }); - } - function removeBlock(target: "base" | number, i: number) { - updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); - } - // --- stepped (up-to) editing (base card only) --- - function setStep(i: number, patch: Partial) { - updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); - } - function addStep() { - updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] })); - } - function removeStep(i: number) { - updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) })); - } - - // --- tier editing --- - function setTier(i: number, patch: Partial) { - setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); - } - function addTier() { - setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); - } - function removeTier(i: number) { - setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); - } - function toggleDow(i: number, d: number) { - setForm((f) => ({ - ...f, - tiers: f.tiers.map((tr, j) => - j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr, - ), - })); - } - async function publish() { setSaving(true); setMsg(null); try { - await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); + await publishTariffVersion({ + currency: form.currency.trim().toUpperCase(), + structure: toStructure(form), + ...(versionName.trim() ? { name: versionName.trim() } : {}), + }); const fresh = await fetchTariff(); setState(fresh); + setVersionName(""); setMsg({ kind: "ok", text: t("tariff.publishedOk") }); } catch (e) { const text = @@ -359,6 +63,7 @@ export function TariffComposer() {

) : (

+ {state.active.name ? `${state.active.name} — ` : ""} {t("tariff.activeSince", { date: new Date(state.active.effectiveFrom).toLocaleString(), count: state.versions.length, @@ -366,114 +71,15 @@ export function TariffComposer() {

)} -
- - set("currency", e.target.value)} maxLength={3} /> - - set("gracePeriodEntryMin", e.target.value)} /> - - set("incrementMin", e.target.value)} /> - - set("lostTicket", e.target.value)} /> - - set("gracePeriodExitMin", e.target.value)} /> -
+ - {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never - wants tiers just edits this and publishes a bare V1 structure. */} -

{t("tariff.defaultCard")}

-

{t("tariff.defaultCardHint")}

-
- updatePricing("base", (p) => ({ ...p, mode }))} - onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} - onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} - onBlock={(i, patch) => setBlock("base", i, patch)} - onAddBlock={() => addBlock("base")} - onRemoveBlock={(i) => removeBlock("base", i)} - onStep={setStep} - onAddStep={addStep} - onRemoveStep={removeStep} +
+ setVersionName(e.target.value)} + placeholder={t("tariff.versionNamePh")} /> -
- - {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */} -
0}> - {t("tariff.tiersAdvanced")} -

{t("tariff.tiersHint")}

- {/* A stepped ("up-to") base rate cannot be combined with time tiers — the - engine would ignore them. Warn up-front; publishing is also blocked server-side. */} - {form.base.mode === "stepped" && form.tiers.length > 0 && ( -

- {t("tariff.steppedTiersConflict")} -

- )} - {form.tiers.map((tr, i) => ( -
- - setTier(i, { name: e.target.value })} - placeholder={t("tariff.tierName")} - /> - - -
- - setTier(i, { priority: e.target.value })} /> - - setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> - - - {[1, 2, 3, 4, 5, 6, 0].map((d) => ( - - ))} - - - - setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> - – - setTier(i, { toHour: e.target.value })} placeholder="06:00" /> - {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( - {t("tariff.tierOvernight")} - )} - - - - setTier(i, { dateFrom: e.target.value })} /> - – - setTier(i, { dateTo: e.target.value })} /> - -
-
- updatePricing(i, (p) => ({ ...p, mode }))} - onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} - onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} - onBlock={(bi, patch) => setBlock(i, bi, patch)} - onAddBlock={() => addBlock(i)} - onRemoveBlock={(bi) => removeBlock(i, bi)} - /> -
-
- ))} - -
- -
@@ -484,139 +90,3 @@ export function TariffComposer() { ); } - -// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The -// stepped mode is offered only where `allowStepped` (the default card, not tiers). -function PricingEditor(props: { - t: (k: string) => string; - pricing: PricingForm; - allowStepped?: boolean; - onMode: (m: "ladder" | "flat" | "stepped") => void; - onFlat: (v: string) => void; - onCap: (v: string) => void; - onBlock: (i: number, patch: Partial) => void; - onAddBlock: () => void; - onRemoveBlock: (i: number) => void; - onStep?: (i: number, patch: Partial) => void; - onAddStep?: () => void; - onRemoveStep?: (i: number) => void; -}) { - const { t, pricing: p } = props; - return ( -
-
- - - {props.allowStepped && ( - - )} -
- - {p.mode === "stepped" ? ( - <> -

{t("tariff.steppedHint")}

- - - - - - - - - {p.steps.map((s, i) => ( - - - - - - ))} - -
{t("tariff.stepUpTo")}{t("tariff.stepTotal")} -
- - props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} - - - props.onStep?.(i, { total: e.target.value })} /> - - {p.steps.length > 1 && ( - - )} -
-
- -
- - ) : p.mode === "flat" ? ( -
- {t("tariff.pricePerIncrement")} - props.onFlat(e.target.value)} /> -
- ) : ( - <> - - - - - - - - - {p.blocks.map((b, i) => { - const isTail = i === p.blocks.length - 1; - return ( - - - - - - ); - })} - -
{t("tariff.bandDuration")}{t("tariff.pricePerIncrement")} -
- {isTail ? ( - {t("tariff.thereafter")} - ) : ( - - props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} - - )} - - props.onBlock(i, { price: e.target.value })} /> - - {!isTail && ( - - )} -
-
- - - {t("tariff.dailyCap")} - props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} /> - -
- - )} -
- ); -} diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx new file mode 100644 index 0000000..3407375 --- /dev/null +++ b/apps/web/src/TariffEditorForm.tsx @@ -0,0 +1,604 @@ +import { useTranslation } from "react-i18next"; +import { + isTariffV2, + type TariffBlock, + type TariffCard, + type TariffStep, + type TariffStructure, + type TariffState, +} from "./api.js"; + +// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape, +// structure↔form converters, and the editing UI), extracted so two hosts can share +// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's +// draft modal (edits an experimental card). The host owns the FormState and the +// submit action; this module owns everything between. Amounts are entered in major +// units (e.g. euros) 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. +export interface BlockForm { + hours: string; // duration of THIS band, in hours (ignored for the last block) + price: string; // major units, e.g. "2.00" +} +// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the +// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md. +export interface StepForm { + hours: string; // inclusive upper bound of this tier, in hours (e.g. "3") + total: string; // TOTAL major units for a stay within this tier (e.g. "5.00") +} +// A pricing body the form edits: a per-increment flat rate, a marginal block ladder, +// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only). +export interface PricingForm { + mode: "ladder" | "flat" | "stepped" | "package"; + flat: string; // major units PER INCREMENT (used when mode==="flat") + packageTotal: string; // major units for the WHOLE window occurrence (mode==="package") + blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") + steps: StepForm[]; // up-to tiers (used when mode==="stepped") + dailyCap: string; // "" = no cap (ladder only) +} +// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. +export interface TierForm { + name: string; + priority: string; + category: string; // "" = applies to all categories + dow: number[]; // selected days 0..6; empty = every day + fromHour: string; // "" = all day + toHour: string; + dateFrom: string; // "" = unbounded + dateTo: string; + pricing: PricingForm; +} +export interface FormState { + currency: string; + gracePeriodEntryMin: string; + incrementMin: string; + lostTicket: string; + gracePeriodExitMin: string; + // The default (always-active) card — its own flat/ladder body + daily cap. + base: PricingForm; + // Optional time/category tiers. Empty ⇒ a bare V1 structure is published. + tiers: TierForm[]; +} + +const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); +const toMajor = (minor: number): string => (minor / 100).toFixed(2); + +function emptySteps(): StepForm[] { + return [ + { hours: "1", total: "2.00" }, + { hours: "3", total: "5.00" }, + ]; +} +function emptyLadder(): PricingForm { + return { + mode: "ladder", + flat: "0.00", + packageTotal: "0.00", + dailyCap: "", + blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], + steps: emptySteps(), + }; +} +function emptyTier(): TierForm { + return { + name: "", + priority: "10", + category: "", + dow: [], + fromHour: "", + toHour: "", + dateFrom: "", + dateTo: "", + pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, + }; +} + +export function emptyForm(): FormState { + return { + currency: "EUR", + gracePeriodEntryMin: "15", + incrementMin: "60", + lostTicket: "20.00", + gracePeriodExitMin: "15", + base: emptyLadder(), + tiers: [], + }; +} + +// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band +// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still +// load (shown as their own band). +function blocksToForm(blocks: TariffBlock[]): 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) }; + }); +} + +// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. +function stepsToForm(steps: TariffStep[]): StepForm[] { + return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) })); +} + +// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped, +// or window package). +function pricingFromCard(c: { + flatMinor?: number; + blocks?: TariffBlock[]; + steps?: TariffStep[]; + packageMinor?: number; + dailyCapMinor?: number | null; +}): PricingForm { + if (c.steps != null && c.steps.length > 0) { + return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) }; + } + if (c.packageMinor != null) { + return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) }; + } + if (c.flatMinor != null) { + return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) }; + } + return { + ...emptyLadder(), + mode: "ladder", + dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), + blocks: blocksToForm(c.blocks ?? []), + }; +} + +function tierFromCard(c: TariffCard): TierForm { + const w = c.window ?? {}; + return { + name: c.name, + priority: String(c.priority), + category: c.category ?? "", + dow: w.dow ? [...w.dow] : [], + fromHour: w.fromHour ?? "", + toHour: w.toHour ?? "", + dateFrom: w.dateFrom ?? "", + dateTo: w.dateTo ?? "", + pricing: pricingFromCard(c), + }; +} + +/** A stored (currency, structure) pair → the editable form. Used to load the active + * version into the composer page and a saved draft into the lab modal. */ +export function formFromVersion(currency: string, st: TariffStructure): FormState { + const common = { + currency, + gracePeriodEntryMin: String(st.gracePeriodEntryMin), + incrementMin: String(st.incrementMin), + lostTicket: toMajor(st.lostTicketMinor), + gracePeriodExitMin: String(st.gracePeriodExitMin), + }; + if (isTariffV2(st)) { + return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) }; + } + // V1: the bare ladder becomes the default card body; no tiers. + return { ...common, base: pricingFromCard(st), tiers: [] }; +} + +export function formFromActive(s: TariffState): FormState { + return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm(); +} + +// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm. +function pricingToCardBody(p: PricingForm): Pick { + if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; + if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) }; + if (p.mode === "stepped") { + // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours"). + const steps: TariffStep[] = p.steps.map((s) => ({ + uptoMin: Math.round(Number(s.hours || "0") * 60), + totalMinor: toMinor(s.total), + })); + return { steps }; + } + // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. + const last = p.blocks.length - 1; + let cum = 0; + const blocks: TariffBlock[] = p.blocks.map((b, i) => { + if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; + cum += Math.round(Number(b.hours || "0") * 60); + return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) }; + }); + return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) }; +} + +function tierToCard(tr: TierForm): TariffCard { + const window: TariffCard["window"] = {}; + if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b); + if (tr.fromHour && tr.toHour) { + window.fromHour = tr.fromHour; + window.toHour = tr.toHour; + } + if (tr.dateFrom) window.dateFrom = tr.dateFrom; + if (tr.dateTo) window.dateTo = tr.dateTo; + const card: TariffCard = { + name: tr.name.trim() || "tier", + priority: Math.round(Number(tr.priority || "0")), + ...pricingToCardBody(tr.pricing), + }; + if (tr.category.trim()) card.category = tr.category.trim(); + if (Object.keys(window).length > 0) card.window = window; + return card; +} + +export function toStructure(f: FormState): TariffStructure { + const common = { + gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), + incrementMin: Math.round(Number(f.incrementMin)), + lostTicketMinor: toMinor(f.lostTicket), + gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), + overstay: "reprice" as const, + }; + const baseBody = pricingToCardBody(f.base); + + // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants + // tiers gets exactly today's shape; the server leaves it untouched). + if (f.tiers.length === 0) { + if (f.base.mode === "stepped") { + // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap). + return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null }; + } + if (f.base.mode === "flat") { + // A flat V1: a single open-ended block at the flat rate (V1 has no flat field). + return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; + } + return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null }; + } + + // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here). + return { + ...common, + version: 2, + tz: "", + defaultCard: { name: "default", priority: 0, ...baseBody }, + windowedCards: f.tiers.map(tierToCard), + }; +} + +/** The full rate-card editing UI (shared settings + default card + tiers). The host + * owns the FormState; every edit flows through `onChange` as a functional update. */ +export function TariffEditorForm({ + form, + onChange, +}: { + form: FormState; + onChange: (update: (f: FormState) => FormState) => void; +}) { + const { t } = useTranslation(); + + function set(key: K, value: FormState[K]) { + onChange((f) => ({ ...f, [key]: value })); + } + + // --- pricing-body editing (used by the default card AND each tier) --- + // `update` maps the old PricingForm to a new one; `target` selects which body: + // the base card, or tier index N. + function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) { + onChange((f) => { + if (target === "base") return { ...f, base: update(f.base) }; + return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) }; + }); + } + function setBlock(target: "base" | number, i: number, patch: Partial) { + updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); + } + // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended. + function addBlock(target: "base" | number) { + updatePricing(target, (p) => { + const next = [...p.blocks]; + next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" }); + return { ...p, blocks: next }; + }); + } + function removeBlock(target: "base" | number, i: number) { + updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); + } + // --- stepped (up-to) editing (base card only) --- + function setStep(i: number, patch: Partial) { + updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); + } + function addStep() { + updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] })); + } + function removeStep(i: number) { + updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) })); + } + + // --- tier editing --- + function setTier(i: number, patch: Partial) { + onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); + } + function addTier() { + onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); + } + function removeTier(i: number) { + onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); + } + function toggleDow(i: number, d: number) { + onChange((f) => ({ + ...f, + tiers: f.tiers.map((tr, j) => + j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr, + ), + })); + } + + return ( +
+
+ + set("currency", e.target.value)} maxLength={3} /> + + set("gracePeriodEntryMin", e.target.value)} /> + + set("incrementMin", e.target.value)} /> + + set("lostTicket", e.target.value)} /> + + set("gracePeriodExitMin", e.target.value)} /> +
+ + {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never + wants tiers just edits this and publishes a bare V1 structure. */} +

{t("tariff.defaultCard")}

+

{t("tariff.defaultCardHint")}

+
+ updatePricing("base", (p) => ({ ...p, mode }))} + onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} + onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} + onBlock={(i, patch) => setBlock("base", i, patch)} + onAddBlock={() => addBlock("base")} + onRemoveBlock={(i) => removeBlock("base", i)} + onStep={setStep} + onAddStep={addStep} + onRemoveStep={removeStep} + /> +
+ + {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */} +
0}> + {t("tariff.tiersAdvanced")} +

{t("tariff.tiersHint")}

+ {/* A stepped ("up-to") base rate cannot be combined with time tiers — the + engine would ignore them. Warn up-front; publishing is also blocked server-side. */} + {form.base.mode === "stepped" && form.tiers.length > 0 && ( +

+ {t("tariff.steppedTiersConflict")} +

+ )} + {form.tiers.map((tr, i) => ( +
+ + setTier(i, { name: e.target.value })} + placeholder={t("tariff.tierName")} + /> + + +
+ + setTier(i, { priority: e.target.value })} /> + + setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> + + + {[1, 2, 3, 4, 5, 6, 0].map((d) => ( + + ))} + + + + setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> + – + setTier(i, { toHour: e.target.value })} placeholder="06:00" /> + {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( + {t("tariff.tierOvernight")} + )} + + + + setTier(i, { dateFrom: e.target.value })} /> + – + setTier(i, { dateTo: e.target.value })} /> + +
+
+ updatePricing(i, (p) => ({ ...p, mode }))} + onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} + onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))} + onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} + onBlock={(bi, patch) => setBlock(i, bi, patch)} + onAddBlock={() => addBlock(i)} + onRemoveBlock={(bi) => removeBlock(i, bi)} + /> +
+
+ ))} + +
+
+ ); +} + +// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped +// (up-to) / window package. The stepped mode is offered only where `allowStepped` +// (the default card); the package mode only where `allowPackage` (tier cards — the +// engine needs a window to be an occurrence of). +function PricingEditor(props: { + t: (k: string) => string; + pricing: PricingForm; + allowStepped?: boolean; + allowPackage?: boolean; + onMode: (m: "ladder" | "flat" | "stepped" | "package") => void; + onFlat: (v: string) => void; + onPackage?: (v: string) => void; + onCap: (v: string) => void; + onBlock: (i: number, patch: Partial) => void; + onAddBlock: () => void; + onRemoveBlock: (i: number) => void; + onStep?: (i: number, patch: Partial) => void; + onAddStep?: () => void; + onRemoveStep?: (i: number) => void; +}) { + const { t, pricing: p } = props; + return ( +
+
+ + + {props.allowStepped && ( + + )} + {props.allowPackage && ( + + )} +
+ + {p.mode === "package" ? ( +
+

{t("tariff.packageHint")}

+
+ {t("tariff.packageTotal")} + props.onPackage?.(e.target.value)} /> +
+
+ ) : p.mode === "stepped" ? ( + <> +

{t("tariff.steppedHint")}

+ + + + + + + + + {p.steps.map((s, i) => ( + + + + + + ))} + +
{t("tariff.stepUpTo")}{t("tariff.stepTotal")} +
+ + props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> + {t("tariff.hoursUnit")} + + + props.onStep?.(i, { total: e.target.value })} /> + + {p.steps.length > 1 && ( + + )} +
+
+ +
+ + ) : p.mode === "flat" ? ( +
+ {t("tariff.pricePerIncrement")} + props.onFlat(e.target.value)} /> +
+ ) : ( + <> + + + + + + + + + {p.blocks.map((b, i) => { + const isTail = i === p.blocks.length - 1; + return ( + + + + + + ); + })} + +
{t("tariff.bandDuration")}{t("tariff.pricePerIncrement")} +
+ {isTail ? ( + {t("tariff.thereafter")} + ) : ( + + props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> + {t("tariff.hoursUnit")} + + )} + + props.onBlock(i, { price: e.target.value })} /> + + {!isTail && ( + + )} +
+
+ + + {t("tariff.dailyCap")} + props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} /> + +
+ + )} +
+ ); +} diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx index 6db953d..9e72c96 100644 --- a/apps/web/src/TariffLab.tsx +++ b/apps/web/src/TariffLab.tsx @@ -1,21 +1,30 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + ApiError, + createTariffDraft, + deleteTariffDraft, fetchTariff, - loadSimSession, + fetchTariffDrafts, + publishTariffVersion, simulateTariff, + updateTariffDraft, type SimulateResult, - type SimPayment, + 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 { 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. +// 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. /** wants "YYYY-MM-DDTHH:mm" in LOCAL time. */ function toLocalInput(iso: string): string { @@ -33,50 +42,76 @@ 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(null); + const [drafts, setDrafts] = useState([]); + const [selected, setSelected] = useState({ kind: "active" }); const [err, setErr] = useState(null); + const [notice, setNotice] = useState(null); - // Inputs (datetime-local strings, local wall-clock). + // The hypothetical stay: entry + exit, nothing else. 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 [exit, setExit] = useState(nowLocal); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); + // The draft-composer modal. + const [edit, setEdit] = useState(null); + const [saving, setSaving] = useState(false); + const [editErr, setEditErr] = useState(null); + + async function refresh() { + const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]); + setState(s); + setDrafts(d.drafts); + return d.drafts; + } useEffect(() => { - fetchTariff() - .then(setState) - .catch((e) => setErr((e as Error).message)); + 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 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, + 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) { @@ -87,162 +122,291 @@ export function TariffLab() { } } - async function loadTicket() { - setLoadMsg(null); + // --- 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 { - 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 })); + await deleteTariffDraft(d.id); + await refresh(); + select({ kind: "active" }); } catch (e) { setErr((e as Error).message); } } - const currency = result?.currency ?? state?.active?.currency ?? "ALL"; + 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 (

{t("lab.title")}

{t("lab.intro")}

- {/* Load a real ticket */} -
-
- - setTicket(e.target.value)} - placeholder={t("lab.loadTicketPh")} - /> -
- - {loadMsg && {loadMsg}} -
+
+ {/* Main: the hypothetical stay + result, priced against the selection. */} +
+ {/* What we're pricing against + draft actions. */} +
+ + {selectedDraft + ? selectedDraft.name + : selectedVersion + ? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString() + : t("lab.activeTariff")} + + {selectedDraft && ( + <> + + + + + )} + {notice && {notice}} +
- {/* Hypothetical session inputs */} -
- - +
+ + setEntered(e.target.value)} /> - - setEntered(e.target.value)} /> + + + setExit(e.target.value)} /> + + +
- - - setAsOf(e.target.value)} /> - - +
+ + {err && {err}} +
- - setCategory(e.target.value)} - placeholder={t("lab.categoryPh")} - /> + {result && ( +
+ {/* Outcome */} +
+

{t("lab.outcome")}

+
+
{t("lab.amountDue")}
+
{formatMoney(result.pricing.amountMinor, currency)}
+
{t("lab.billedPeriod")}
+
+ {formatDuration(result.pricing.periodStart, fromLocalInput(exit))} + {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()}
+ + )} +
+
- - - - {paid && ( - <> - setPaidAt(e.target.value)} - /> - {t("lab.graceMin")} - setGraceMin(e.target.value)} /> - + {/* 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)}
+
+
)} - -
- -
- - {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)}
-
- )} + + {/* Sidebar: lab drafts + the full published history; click any to price + against it. */} + +
+ + {/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */} + setEdit(null)} + title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")} + width="max-w-3xl" + > + {edit && ( +
+
+ + setEdit((d) => (d ? { ...d, name: e.target.value } : d))} + placeholder={t("lab.draftNamePh")} + /> +
+ setEdit((d) => (d ? { ...d, form: update(d.form) } : d))} + /> +
+ + {editErr && {editErr}} +
+
+ )} +
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 7ed8dd6..5e36d46 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -669,10 +669,14 @@ export interface TariffCard { priority: number; category?: string; window?: TariffWindow; + /** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */ flatMinor?: number; blocks?: TariffBlock[]; /** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */ steps?: TariffStep[]; + /** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence + * ("any presence in the window = this price"). Mirrors @parking/shared. */ + packageMinor?: number; dailyCapMinor?: number | null; } /** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including @@ -702,6 +706,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 { export interface TariffVersion { id: string; tariffId: string; + /** Optional human label, stamped at publish (e.g. carried from a lab draft). */ + name?: string | null; effectiveFrom: string; currency: string; structure: TariffStructure; @@ -723,6 +729,7 @@ export function publishTariffVersion(body: { currency: string; structure: TariffStructure; effectiveFrom?: string; + name?: string; }): Promise { return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); } @@ -761,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise { return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) }); } -export interface SimSessionLoad { - identity: string; - enteredAt: string; - exitedAt: string | null; - payments: SimPayment[]; - category: string | null; - tariffVersionId: string | null; +// --- Tariff Lab drafts ------------------------------------------------------ +// Mutable experimental rate cards — the lab composes + simulates these, and +// publishing one goes through the normal immutable-version path above. + +export interface TariffDraft { + id: string; + name: string; + currency: string; + structure: TariffStructure; + createdBy: string | null; + createdAt: string; + updatedAt: string; } -/** Prefill the lab from a real ledger session. */ -export function loadSimSession(identity: string): Promise { - return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`); +export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> { + return apiFetch("/api/tariff/drafts"); +} + +export interface TariffDraftBody { + name: string; + currency: string; + structure: TariffStructure; +} + +export function createTariffDraft(body: TariffDraftBody): Promise { + return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) }); +} + +export function updateTariffDraft(id: string, body: TariffDraftBody): Promise { + return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) }); +} + +export function deleteTariffDraft(id: string): Promise { + return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" }); } // --- Subscriptions -------------------------------------------------------- diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4c5168a..7d41c68 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -319,25 +319,30 @@ export const en: Catalog = { bandDuration: "Band duration", hoursUnit: "hours", egHours: "e.g. 2", - pricePerIncrement: "Price / increment", + pricePerIncrement: "Price / increment (per hour)", thereafter: "thereafter (open-ended)", remove: "Remove", addBlock: "+ Add block", publishNewVersion: "Publish new version", publishing: "Publishing…", + versionNamePh: "Version name (optional), e.g. Summer 2026", publishedOk: "New tariff version published — it's now the active rate.", defaultCard: "Base rate (always active)", defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.", modeLadder: "Hourly ladder", - modeFlat: "Flat price", + modeFlat: "Flat price / hour", modeStepped: "By duration (up-to)", + modePackage: "Window package (one total)", + packageHint: + "ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.", + packageTotal: "Package total", steppedHint: "Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.", stepUpTo: "Up to", stepTotal: "Total price", addStep: "+ Add row", steppedTiersConflict: - "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.", + "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.", tiersAdvanced: "Advanced: time & seasonal tiers", tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.", tierName: "Name", @@ -513,21 +518,26 @@ export const en: Catalog = { lab: { title: "Tariff Lab", intro: - "Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.", - loadTicket: "Load from a real ticket", - loadTicketPh: "Ticket number / identity", - load: "Load", - loaded: "Loaded session {{id}}", - tariffVersion: "Tariff version", - activeVersion: "Active version (current)", + "Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.", + drafts: "Lab tariffs", + newDraft: "New draft", + activeTariff: "Active tariff", + published: "Published versions", + noDrafts: "No lab tariffs yet — create a draft to experiment.", + edit: "Edit", + publish: "Publish", + delete: "Delete", + draftName: "Name", + draftNamePh: "e.g. Winter proposal", + saveDraft: "Save draft", + savingDraft: "Saving…", + newDraftTitle: "New lab tariff", + editDraftTitle: "Edit lab tariff", + confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.', + confirmDelete: 'Delete lab tariff "{{name}}"?', entered: "Entered", - asOf: "As of (now/exit)", + exit: "Exit", now: "Now", - category: "Category", - categoryPh: "e.g. bus (blank = car)", - payment: "Payment", - paid: "paid", - graceMin: "grace (min)", price: "Compute price", pricing: "Pricing…", outcome: "Outcome", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 01239b9..9354c66 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -322,25 +322,30 @@ export const sq = { bandDuration: "Kohëzgjatja e brezit", hoursUnit: "orë", egHours: "p.sh. 2", - pricePerIncrement: "Çmimi / interval", + pricePerIncrement: "Çmimi / interval (orë)", thereafter: "më pas (i hapur)", remove: "Hiq", addBlock: "+ Shto bllok", publishNewVersion: "Publiko version të ri", publishing: "Duke publikuar…", + versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026", publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.", defaultCard: "Tarifa bazë (gjithmonë aktive)", defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.", modeLadder: "Shkallë orësh", - modeFlat: "Çmim fiks", + modeFlat: "Çmim fiks / orë", modeStepped: "Sipas kohëzgjatjes (deri-në)", + modePackage: "Paketë dritareje (një total)", + packageHint: + "NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.", + packageTotal: "Çmimi i paketës", steppedHint: "Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.", stepUpTo: "Deri në", stepTotal: "Çmimi total", addStep: "+ Shto rresht", steppedTiersConflict: - "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.", + "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.", tiersAdvanced: "Të avancuara: nivele kohore & sezonale", tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.", tierName: "Emri", @@ -525,21 +530,26 @@ export const sq = { lab: { title: "Lab Tarife", intro: - "Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.", - loadTicket: "Ngarko nga një biletë reale", - loadTicketPh: "Numri i biletës / identiteti", - load: "Ngarko", - loaded: "U ngarkua sesioni {{id}}", - tariffVersion: "Versioni i tarifës", - activeVersion: "Versioni aktiv (i tanishëm)", + "Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.", + drafts: "Tarifa laboratori", + newDraft: "Draft i ri", + activeTariff: "Tarifa aktive", + published: "Versione të publikuara", + noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.", + edit: "Ndrysho", + publish: "Publiko", + delete: "Fshi", + draftName: "Emri", + draftNamePh: "p.sh. Propozimi i dimrit", + saveDraft: "Ruaj draftin", + savingDraft: "Duke ruajtur…", + newDraftTitle: "Tarifë e re laboratori", + editDraftTitle: "Ndrysho tarifën e laboratorit", + confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.', + confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?', entered: "Hyrja", - asOf: "Deri më (tani/dalja)", + exit: "Dalja", now: "Tani", - category: "Kategoria", - categoryPh: "p.sh. bus (bosh = makinë)", - payment: "Pagesa", - paid: "u pagua", - graceMin: "afati (min)", price: "Llogarit çmimin", pricing: "Duke llogaritur…", outcome: "Rezultati", diff --git a/packages/db/drizzle/0021_tariff_drafts.sql b/packages/db/drizzle/0021_tariff_drafts.sql new file mode 100644 index 0000000..d25a977 --- /dev/null +++ b/packages/db/drizzle/0021_tariff_drafts.sql @@ -0,0 +1,14 @@ +-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes +-- experimental rate cards here, simulates them against hypothetical stays, and only +-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable — +-- a draft prices nothing and signs nothing; experimenting through real publishes would +-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md. +CREATE TABLE `tariff_drafts` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `currency` text NOT NULL, + `structure` text NOT NULL, + `created_by` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); diff --git a/packages/db/drizzle/0022_tariff_version_name.sql b/packages/db/drizzle/0022_tariff_version_name.sql new file mode 100644 index 0000000..7d1f676 --- /dev/null +++ b/packages/db/drizzle/0022_tariff_version_name.sql @@ -0,0 +1,6 @@ +-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave +-- rate cards human names; published versions were only tellable apart by effective date + +-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the +-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed +-- publishes are fine. +ALTER TABLE `tariff_versions` ADD `name` text; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fad373b..9036507 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1781886300000, "tag": "0020_entry_presence_bypass", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1781886400000, + "tag": "0021_tariff_drafts", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1781886500000, + "tag": "0022_tariff_version_name", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b964183..bc1921e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -316,6 +316,10 @@ export const tariffs = sqliteTable("tariffs", { export const tariffVersions = sqliteTable("tariff_versions", { id: text("id").primaryKey(), tariffId: text("tariff_id").notNull(), + // Optional human label ("Winter 2027", carried from the lab draft it was published + // from). Stamped at publish, immutable like the rest of the row — versions are + // told apart in the UI by name, not UUID prefix. + name: text("name"), // The version is in force from this instant (latest with effectiveFrom ≤ entry wins). effectiveFrom: text("effective_from").notNull(), // ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float. @@ -329,6 +333,29 @@ export const tariffVersions = sqliteTable("tariff_versions", { .default(sql`(current_timestamp)`), }); +// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one +// exception to "editing publishes a version"): a draft prices nothing and signs +// nothing — it exists so the admin can experiment in the lab without churning real +// tariff_versions (each publish is permanent; experimenting through publishes would +// bury the history in noise and risk a wrong card going live). Publishing a draft +// goes through the normal POST /api/tariff/versions path (validated, tz-stamped, +// immutable). See wiki/concepts/tariff.md (Tariff Lab). +export const tariffDrafts = sqliteTable("tariff_drafts", { + id: text("id").primaryKey(), + name: text("name").notNull(), + currency: text("currency").notNull(), + // Same TariffStructure shape as tariff_versions.structure; validated on save so + // the lab can always simulate it. + structure: text("structure", { mode: "json" }).notNull().$type>(), + createdBy: text("created_by"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // --- Subscriptions -------------------------------------------------------- // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL / // month) instead of paying per stay. Mutable master data; every USE still produces a @@ -513,6 +540,7 @@ export type SetupStateRow = typeof setupState.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect; +export type TariffDraftRow = typeof tariffDrafts.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect; export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect; diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index d64e59b..00461ae 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -197,24 +197,44 @@ The admin authors the rate card at runtime — no hand-seeding: - Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices). -### Tariff Lab (simulator, as-built 2026-06-20) +### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05) The tariff engine is a **pure function of time**, but you could previously only *exercise* it by waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap: -price a session at **any** instant against **any** tariff version in seconds. +compose an **experimental rate card**, price hypothetical stays against it in seconds, and publish +only when satisfied. -- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available - on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical +- **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable** + table — the one deliberate exception to "editing publishes a version". Rationale (operator ask, + 2026-07-05): experimenting by publishing real versions churns the immutable history with noise AND + risks a wrong card being live while the admin iterates ("we risk taking tickets with a grossly + wrong version"). A draft prices nothing and signs nothing, so mutability is safe; the ONLY way a + draft affects a customer is publication through the normal `POST /api/tariff/versions` path + (validated, tz-stamped, immutable, effectiveFrom-guarded). Drafts are **validated + tz-stamped on + save exactly like a publish**, so a saved draft can always be simulated and "Publish" can never + fail on a card that saved fine. +- **API** (`apps/server/src/routes/tariffs.ts`): `GET/POST/PUT/DELETE /api/tariff/drafts[...]` + (list `tariff:read`; mutations `tariff:update`). `POST /api/tariff/simulate` prices a hypothetical session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you - SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity` - prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are - **read-only — no ledger writes.** -- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any - historical), set entry / "as of" times, an optional payment (with its grace), and a category; or - "Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/ - settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real - overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay). + SEE where the daily cap flattens or a window shifts); the lab passes a draft's stored `structure` + inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still + exists API-side but the UI no longer uses it. All **read-only — no ledger writes.** +- **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every + lab draft AND the full published history** (active card first, then older immutable versions) — + click any to price against it (drafts send their structure inline; published versions go by + `tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`, + migration 0022, stamped at publish and immutable like the row): publishing a draft carries the + draft's name onto the version, and the composer page grew an optional version-name field — so + history reads "Winter 2027", not UUID prefixes. The main pane is a pure + **entry/exit** pair (the 2026-06-20 ticket-loader, payment, and category inputs were dropped in the + redesign — the lab is for composing rates, not re-evaluating tickets) plus amount due, billed + period, overstay/settled state, and the curve. **"New draft" / "Edit" open the composer form in a + modal** — the *same* form the `/setup/tariff` page uses, extracted to + `apps/web/src/TariffEditorForm.tsx` (new drafts prefill from the active card). Per-draft + **Publish** (confirm prompt) goes through the normal immutable-version path. Prices via the same + `priceSession` the booth uses, so the lab and the live booth can never diverge. + See [[booth-exit-flow]] (overstay). ## The pay-on-foot consequence diff --git a/wiki/log.md b/wiki/log.md index acad2c3..ee1d1fa 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2282,3 +2282,41 @@ ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so th silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3 (≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days. + +## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels + +Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is +PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400. +Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package +("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights += two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to +table). Engine charges once per contiguous run of increments the card wins, tracked across +rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with +flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards; +flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card +dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend +card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green. + +## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal + +The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing — +churning the immutable history and risking real tickets pricing against a half-baked card while +the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on +[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one +deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the +normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish, +CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar +lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket +loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and +reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm. +Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate. +Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow). + +## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history + +Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions +(active first, then the immutable history; click to price against by tariffVersionId), and +`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like +the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew +an optional version-name field (never prefilled — republishing a tweak under last season's name +would mislabel history). Details on [[tariff]] (Tariff Lab section). From dc2cdc0a91a8d2fa2199c960be954cb7f98575af Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 15:23:15 +0200 Subject: [PATCH 4/6] feat(web): self-host Chakra Petch as the app's primary face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The booth is an offline appliance — no webfont CDN — so the font ships from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total), SIL OFL license alongside the files. Chakra Petch leads all four family tokens (mono/display/ui/body) with the previous stacks kept as fallback; index.html preloads the two everywhere-weights so first paint doesn't flash the fallback. Not a true monospace — .num/.tabular still request tabular figures and columns verified aligned in the built app. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/index.html | 4 + apps/web/public/fonts/chakra-petch/OFL.txt | 93 ++++++++++++++++++ .../chakra-petch-latin-400-italic.woff2 | Bin 0 -> 10644 bytes .../chakra-petch/chakra-petch-latin-400.woff2 | Bin 0 -> 9756 bytes .../chakra-petch/chakra-petch-latin-600.woff2 | Bin 0 -> 9968 bytes .../chakra-petch/chakra-petch-latin-700.woff2 | Bin 0 -> 9900 bytes apps/web/src/index.css | 55 +++++++++-- 7 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 apps/web/public/fonts/chakra-petch/OFL.txt create mode 100644 apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2 create mode 100644 apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2 create mode 100644 apps/web/public/fonts/chakra-petch/chakra-petch-latin-600.woff2 create mode 100644 apps/web/public/fonts/chakra-petch/chakra-petch-latin-700.woff2 diff --git a/apps/web/index.html b/apps/web/index.html index ff9dfef..d276f41 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,10 @@ + + + Parking System diff --git a/apps/web/public/fonts/chakra-petch/OFL.txt b/apps/web/public/fonts/chakra-petch/OFL.txt new file mode 100644 index 0000000..9cee5a5 --- /dev/null +++ b/apps/web/public/fonts/chakra-petch/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..89fda8b9bbf9d291aea00c6823f7ecf4318338b3 GIT binary patch literal 10644 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2 b/apps/web/public/fonts/chakra-petch/chakra-petch-latin-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f7a66022dde0ab1d862d202807f5988453a9bdf4 GIT binary patch literal 9756 zcmV+%Cga(6Pew8T0RR91045v&4gdfE0AKt7042l#0RR9100000000000000000000 z0000QcpJMU9E5lVU;u?A5eN$2M8+8lgL(h~HUcCAhj0WS1%yNghJPF7PzBuiVdDT0 zZ~ic&5wXVs+)P;%)tkMt|LX-#hltM$PK86bv<*u_bfSvR>2f$L3eiOY%u8#7c?5IV z2o_8UyI_kcX*72h_pfJW_Wl>50strg zR7q3;b%h~TWC3rht3tFt@NVn<{{aEl*U-?^5Dn3sI2Ixx5`GYHV8+KB6BBXc$fnuc zrtRy@mW>ImZQJUWT{o6jmet*POLcCAjS);84(=F5`G3BHcHf@`O+0}#Nq8}iGl)A0 zhBkirnz~DZ6!I33=u*m^lKy)o<+HK?Se|H$wkZ#lTxv1%o=*>mKhg1*Cy^(WN~BV$ zR4S23q!OuQ=4K^xGli;c46zYPMMp~}*Y#LF%0Ku!{@C8ou!%uQCYcm}Oh1HrA--Sx zH#?olt;~dPfK(>LuObwnO)Q{H{G$)&pG<(KCPWjS0KltQA_=_fw`vv$6spy0|GG6Y zwMIo4v&26woxYtUl=|sH*_8UCKrQF6NEE`wdW7TuRMqt$2q_)uRobFq6H>-($uMPE zfPcq391cf`q8bHtxDc*@D{0CvEMfw8TB&7n#%z9>$W)i!-k#D%5Azb=_NL`Yu57o? z0UAfaHvz(L&t1O_>-|q@(kmrP2(qL>L}WV1U`2vRu;)MSL3W0JUD}-oC;uV=><%ys zVi*$&Bor8nLb4oDOos@OCRQoe>Xlm~lQnBg&(#if+vOtex(6{}5yJ`QjkmDxeSEnQ zU~g4ufU!u=Z~C=k#SAYix;0`U<=P|i@<&OiroQvcZBcCy(PZZPhu zAJW+twAR+ps#`@X8Zw)qa? z9_u~xemC+`p34(?xWeOrr|r zDA}t~F`7Uh9F?KTPi}gpd*aE=7MB+=kDYs8pjcDx|Tws zPB*L#Y+ih5>XQdLMNxd12Pjjc8Qp5nHhGnQ{p@_18rm>*K3??T2X5Y4TDb9!$-8-9 zhVNDQSnJSd;|A5zRW$S-$3us@vUP0T2Gwj!)mlHoAK~bGPUn5G?HramQ{tOq$~XlG zZTBufdG?_AT6}XEbpaGBhvG*LiwO%0>*$-_62-61gjzz=zv&k*KC^?y%rdM?wN$dP zv6FyfuD$@tBFK^@D_SdB=-7A6kHL~)m*j}xLUVi9#&@ahQroq*1+F5qR3HJCfGDFS zPKr2jgsIl5$;l(lcL<9#3PoYei!ru7Um05G(lf-4iFSSfe5tY&o|HAXHyzX$^#7$0 ziV-e|KObzakl?3HUw~D%m_{y{g(i24`~$+(AH_)XVNC9zlY7GxwCSw{I{J5KtUg>`lj)6`SPs>zb#`zVPAj>i zw*tIVCcv+J(Nc2q#|a~qDBXhycDYjI!fGpS?c0FScy-^Jp%Hpn(iViX-S+5p#4*R6 zblMqbopXVe%_S%f3VRHWoPt*aUhCTY?!mVmBV zD|j0OwI_=@Kr!uxZfB29)vMDT(HV~EG{<$!NuT1hrffk+T|%fcsxS(})#O~H5%e3U z^^(LYzlGNftYThUQ+cpAI&=;~xB9x_hE?2R1KlDKZ0;E-*b!J#W6*shURZ!yBCk9z zFLin;M4uoEk<0Y0{k{}dqt`Nyst$4X!i*aWJMf}S!s5?6&8lKF0jaJO3YRBi)w2ZN zwj4{%(<)GNG+kH#wCrR}6H_!vUzC6u7bV?Zth!WcxHaGi!m#QnO=;6DBcw!uDfO@% zPdW<(xZpB`lw+>?z*k1~w8;#@a-j;;k6m%j!NsqAfkw# zBa6LR4y{)H0DOkW=sY6ruG-vT z`M^@q#iFr(4%n+@a$;o`rk6Q2s}@C1Mre*CAMB`Up@e|FCMbY{qFy*UDgcu;SAtF3 zDu0xhxb>d{UZ@j@4hB%YCbE?KDiY4~;uAr7<9GI@xYKpgBsM-uIJF6HZI9y^^D<8F zrIq9Zv!>3cbZT){3w^-L&Qp?8Dh~H$@kk3#tjxGj)fRzv9`Ed2dWdkxNV)OE5+Ebl zfq@_xE7lce#2)HpJVJKjJ4sm?m8`7EgbO8mM<%78DxnfS?`;)!Rno29;{{1lbIU4( zfqU`|>Fgv5ipjwCe6dUuf-OuEU#KV)_9mV05D*AY2m}g4;f%@+4T~KdBaD-CO5bO> zaL;<)d@%T6@G*r0N3NWBaLm--pgP?`Di@23r=xszmcM1ivAq23F19`8?ntI}1txbd zHWbsvvUjPNF30}t-HvQ~2z!xG&JbYN2%Q{3Emp+E(r8jUR4?6${#y-q?UV6p%q zu7yeviHjsg8k?G$Fmb))rN$l55zOe_#J44ia25(gLd1ww)+u0X)Cf(ngAM~G_C;!t zkP?zCVjw|E3NlhrKm@8>I%+hzyBqd2?T--gt)&noadq+7k{CmnpT=YfB|nB?d7_A7 zN+_j_^4)h=?TO=tSQcDUEO}a_m4y*kmK7%?Vv-CjCO)_^*Hmb>$d41lBLF^u1TkHb zt3=F5kdlIo)M9BwVL}ip(ov%cikgZkp_DSpgW6mj^)%!*rdsM+vrb`(MD2s7*g=N@ z6Z^7A1VIo4fngYi;XF`4pduYLnj|Zyw@WP$n^b3NQ&Pv&(;$s|EVR%nbJyBl6kgsm zIg&z#gNZY0j0Uk&=rCYn|Ha~hgf?N$Qrg{PkCy3gwa`!!KH1?e7FiPtod|o5!ohvMNPQLt5XB`B82 zm1B%)aYR<(8d)2Bpy-!~WQLN4l0s@u7G1#0fDlxm=rFA^IY4=#07nak!a$d_5rXy? zz~W#3ETZFJ6d)jbFbhIi4Dqf!cQJJJ+NIF)-_R5dQlB9`!vWnkXxweV80$sFwhJuS z^1S7cHdkzy*$aY&b&E?K$`U%0r5(#mmZKOz&{V+D=H3Ov;5aIjqlT;A z5O$nyxy4ii74PfsGN5RkDNkd0AsDuri>n}~XeGFqi~d_M6=MIXLlAQM?}Pf3HDW>_ zd9xlIjPJfE4g)0c-jG6Q>S=mMIiP8;zrkFnEC_@)j!g1y>|DUuPCda$_AT$k#F#ILEwaTu)FC zU@(t9@h6<}rsTK+c7DNLZyEq?%$OY!WFsJ!r46K^pk$FK0!ztt%TFZ!!GG;+VdBkm z$^;UTmA1T*D$w}bII6uBz-}Sy5xvpHdi3xHpN4NyL zooSny)&Q`==v^mq*c`913pnwA%jheC3juULz^2!6&iK^--)wvfHvW(M0ps^LZ#*$B zA2UavZ?Alt_K)K4i+`Vk02~Wk>2-8~cs;tzHkO*MSR9-De=nc8;jp{DG~}jZ`W^Dn z9;fYg#9sRhdg__aeS^Y*BWEt$O~&TI$5b3#ex?Z#CR_x8NO9sNNR;HHqfYt4d1FN6 zW=Nx?l_67>Sx5%6yZ8jYG*G;4F*&rW#ZmIH3P?~Vb#_|{)uo9Bv`&iKyX ze(>A|8-4Q1L4R0pzN@-ywL>pDDjFs`4ECJ3aWx5xH&0V|!TAfs6JR>N$AZL)5-o;M zsA^Kl#AGB=q*BmyuCM4^Iyq({6ev}sSh*5Ulxa|_PQ4bDS_P}{n_mt4;F8NOu(`+z zf%I-Jzy?690XBgd|A09UfwniuX8;m_?Dz}`gFv8Ah(lxek+3}-5-TI$qUfb!I2p^u zd!V50QGqUOdhc@7N<}JgY4&C`y*utA$)Zkq|;)i3pgC7!$+T z;#ApBp)jjNLr0Nm8pN(e$6~$$1OO#@LdD}W$ zIk-y)_sRp<%a5GBYOAukY!9t5)>?0_9fk+xRa?5#GNZ7#eZM;V7`^H)UTnF&g%2*? zk>c`2wmNyy2Y{PG+bSw=Dl5B+=iN5OICuGS0~Z9-QF+D$2YGVXWSW!H3P4z z97CPOJ$v(DLJzsEv;C2*Ty7eoV`pWYJo*b)qc?6Uc*}cviR1#N6ZC(RCwJ$(+1vdQ zo#p69A88dSxVhuj(c5E2b3L*_c?emQjwQ9i-nVspC=JzMIp!r;k&x|C8)U&vnkQ%e z4m^Kq=X-NiKtdp|qiAxczbP^Z@|5A_kCORZ4Duu&jcdeAM7lyufE36e{SGHsr#$8A zFJ;UW=p%|reJ2+m-@`DcO2Ikx_xc(vKZR}%LyIrud|t{D{%j%od(m{&E;FfyXSXNd z@+nzeI)j(WexI|1LI~D5@ZFEAl#gY|bZGH2ffJm9sOyafU;z5e3cZO9_G)Ul&Kkj< zG@Vt5i2`NFQ2GU4iC^a_blQ9(JL5&~KtVLhpnG=g>6}_$Sv}+Z0jtCe9H7cr(Rymi zk0)P(u}srRB*zpIJsN1>WTcklcX^9Ijpl2jb24KRn|+lV1rkgGWl3~!60Wna4kxJ= z;S@gB_shqke$>Z(YNR`SoE!;A(U4(L4q-(?$Rdt0AS1LcaDyDvFy}$hQEvyE>&h_l zu`K90f@B!PFJCmLcxYC4sf4=|$jY>dDy0)L)TQ z$7GId8yFP1`-&zlL*6GCX9Ch+jTE|!rj&-|l-+3tCt3oSN{~7@Pwv|CM&T48=qp+e z1dIVM(Bj;YkWdQ<&{?2}g%oe55n6LG08o1FP}{Ief1M}L1$+w7R7_KbSSo}zt#jjS zItx5+;tGbv!NWRFvx9?c?!Ec`ES8P5+Qv&+GgyWf38hk_;= zY#m6ZZT;urLGGdWVQ%f+o~96&t+x9cNiA&E4_ReFwP*yxgv(>5NTaC^MIiQn*RdZj$kD|$^cG2wkPIAB$K z=r{mv%*S4K0}dSo=FW1Cp%Jg_?ZA*8LcmFLh#(eXH117Y7tu0nmP|TM?I!JMFvbI- z+fIHa*Xm~IoYr`jd?<8N$F8Pk=8!4zzkYnK*S+aCBa&!|2dX2AwW(+V9Spy8r-3X%=X+zBT@lgWqit7K1)iY30jg_Y*BK38{ zX<8x`*f%GX&0VMjo9WdwrP6CTT9TE62L5DX|lAjGTDiXb{RHpl$9qt-ei&Q zZ32yTIZ0ldP^&K%7VfQ>@3Ix2`cHJywpH6=#v#@8+GGkg8pW!B@n2JA`<^4=c0L4O#P_q>TKhS$1PJ@q(=SKaornr_!WZppMwXeRl6o+3>^>4=vh^ zDycF_Wm6?qCaNIY8bZJ|6%zys4lNa{a*K=Kq&M7mN|<@e@O*amm4yB8NHf+_lG1OZ znXkQ3-#E*r2t)_|6W;$G4LmclC{dM+iWRme5if*>+$Qu8V{jZ2%`Or1Mh>f$#!v7^ zX2w^#l)K>@Prz4=Tt|Q6s42Bf0s=63Ih5NZf z;zK`Qj~%arq8tB&@= z`*A-IlVWAS`}eLrxFbnk_K3Hjgy7>p;j06?`Y10i6h@l|Q$D2(wx~d3QmdjyQcZmnh62cbh8Hi`~qT+2CdShuR5QSy&Ck;1(|@A|CFV(vd^=1 zKh7K}*4^OW;OhYKTedaxW+n1rfxe5VS>dPbsHu@kxV_m@z*?%go37@IXRD zlE0DUmKnL^%>LcU6(vc-P1#NGUEPzWfYh+|HpPt$?7G8cWy2(SGPm2{WY`b1;VI`M zS_uSMO~e-z93#mDk{lnB;12j>jg7Z)5TES#6eJNmyuURy!y}S*ABUZ}tCh{H)4j7u zB0|19S2AXBhG3 zAEL|*d!|b-(Cxz?<4@I?@UMY9Sy_WyC7$Jf9l6)yHrxP~e!WC*&dFIPuDP%8k@OrV zU~IHjum4M>N+PCl&)^rx%rokot&2H1Gm*YKE0*I9_-o|_K-?w7<>fm_atkpZFK=s$ zA|SOmc<5T-ltp|XAbS+jh+p16RD#bUYW4ah5VQ3QEH-_has7kL&)*$KGIxB}_&oud zZKHbx5TlBM{CrFL&FvA8txip?RNVdSIdf!5(<+U|?XjX%G|plgNa%c93H&P^^Wv-v zqTuqST2nd{a$)E*^|pPVVz`NbgZ^=3S;ivpe_w+iFZrR-vNCe>fkp>U_L_NwzG@R1 z+zbUE^ubxVxmHp#V_rm}+q2Nim6eC{bL92iRD|>$E~q7~NwSitbF1T*t_gleu~1rV zG|CBx$Pf{MP3M}@EicvT#*B>Nj6k-pDa|t2p{DQ{XbN(2Mt#VnHTgvnWFLaN7vXFa zH7&BF_sO&-_5I`bRRmZ_onB(ttJvEu=@z+EN>)H>VKtRFAM!6}8#s-gyYX`hhg4@q zbAY=DXZK*19!1r-NkfmSeN=1e!Yo}GuyECN_oA#b{~iC7wbb^5S^a;W{s-QU){^@7 z&153aw;z9EG;SkVH*RkiK!7Z&3Y~uZg4BC%^zkd;Z=(NgzxsNqzP+~n`apRs(1~S_ z28=z@p2$tdL&T2kfzvU44Snp?pj3IU-z9fRZ07?g6JhC<4pV-9<5T7B#k6HO~&9l z*(Hb|)v9QG5Z^v==iiywj6Y08uA?{Z58S4`Wkb!H+@_#2Ex+kxy0xk&hiG=s?Foq# zn|O-f!R6{RDOI<<4Tis)z#URaib#^*S$d<@eoc~}NYd-?6#N?gSxLzn9NIH@m9jHt z7nK&ojeEfa|>>RomBCD1KD;XR07VSFandnm9R8HLk0lxZL$oC(||YrrJ*Gl-Y~2l{%nV7hBbh z55nMqs|R@w@5R$1+5#bRK}?UA+%7uR+7~1JXjXKms?%xX{*ycTeb)@E9%ZmQgZ9L5 z!hw4gfAWYXMcOkSNQjNbA(N@~^s&VX<`VRt|$ z&Q%N1bqP#e$}*eIN%y^qO)#?)VEmlV@s8xRzPD$1aRByLCO2-MT#&rN_d2=`_NY1e z%7J}w02bNQ9>IaFx2i$IeoK-yQZ0TN*Gg-E-S9OdRv(5|HlD5tW!rhqsgNpgTtUzg zxZnzDMV_@$U7B!@B%N)9Hv%el;E&=((-w&ba>#q>&mhLfU+NEaM2h?uqtpn@11BDU zn&h0VX=?kGM%DrD@6y53x$D|!z9#p~cMxJ$19NBfwSzPN2zq6l?+b}(b*eh&_TY(c zc20(i^TEnjr(!;E+4)9-Te)wp*9u13_Z5VWKW8b?K=T8)|Jn^BXg zQzz1CA=kL4Oem55(WcZ@k7$rt?i^NGX6;S*9=7h?EFrANWrZpCXuU8y$&UoILYQ&$qxJ$sk< z%zUMtSEv~_N$hz>+w+6+w|CjU{RP2)WjD5RY10liP{NNEsfezBqrt3)Q+)@fk7o<1 z78iO$z~_))dwj9<(69nrt=U7DmFF$AvkxmFX4QyTyv+k_c*U$i*r;wO)GS>eaC8*P~Cn{eG_Yn!ot6e-^14D{9U#e%6{kQ<@Fq@A zbi06<5KE+zZ%A@FGsWeax63*gpb%Asg-4J*yHrW9e=}twJVmyD*qV_sIQrGw)RbWv zP^(ui+upu$pKq1T_T5XQc1Kg8<(w8kOuxRrzo_4vCzh;S$=S4sJNd+Dywo^Z9%5ZF zbZ}_d*Wq&ME(|eithKcl0d96*M0VCjh?Vx`bfmxhhG2q%19?4hvhW(y#FQk7u(Q2F zG+9(Jb;j3*l=7EI#mkhu)F+6xXS${bd2$-Wa10{_BjPKfhXi18$g>iH>!NeI_xj&L zRP21hldv~_kT^wLBw+RW_W45_vxECWx`Mkx`aBO7ryzWbueq>jKu2`TA?U8m%JsJr zWh)ipSu~%gfRfqS%W{{gFOAq9>TUr7O|IN22_Z7k$4M?;uVaFh^Vvc+Ox2|+ye47~ zkN9A2B{xnk_m)5lyKer;`RZ){d@(0$`MMq)mLMY4059pOPji6qVY`Ybu7H=`z7yCP zD9}t`BeCx7j2bbcB%<1%Z_TC=tWg_1xH;{UQSwhOw|#>yH;nAca0)8^~iB5WYod%pj5ZuCbz zSH!&l0RlYnTZYkDxRP#%{R;Ty>&EB!=6?q>{(o!Z*UH-;gPvy*lf@Uf-d{28#O<-E+OID!QbdZ%fy zNh#+}Vr0XJSX(n6cRGR6Cvr5@`FDh$v;GQf;%DRcZaSnl4bxVS{!S|8;>MLgf^#f6 zo8qbxNfJp5?O=d|S4&$b-7uD1&FlNRce~E7U$GWXj#OXaPgY)=i(cAIQ184o#3&Qq zEGeYZOBo55!$153*2+jml4J*dHQ~2aNXz)Nil9VilFX*)?!&PgDMj#$==6WZg zY1_OrJ+*lx3t(Zc5r{J!rkBe1oSE@3tBgqBUW`Tk@67uIBf z?I&-oDDUyhF|{bsEtLoXHjuHCjB5U71qG%lgFzYtz(?^^cLY19?kJ}K#d1|fcM87l z?o?9x?le46;v@;UQ-cd?BnV5_D^|z2mIk(=>ZQuW9Fg+WudgAelA=PPIqDSBD%GG^ zg%Fuam1|U`P@Np5>d0?pR*fSmWqMuEgXDJLD$C!jBz1n917=T z);C5986}QHfhX_IESjd=0#sY_5|^tGX$8V1E=cxpOQkr((spFOj68V^x2l;{US&2QRI6F9#cKqR0r{_eW1 z$PvYkT4x%b5&?e0SL%*??%!i->%j&*@X#Y6LWPCpVE@VpJoZGnXVyEWU!=FUSB=Js zGwAtQzPJtU;zGYmBD;O<-c_n*vRf)Svl)gd)MVwKQY#HXN#&&mO)MIvM*%fWRM5)M zEK`d%bG6!|-Emn)WYfv9+)TOVSzw|0X3--Vym3LEE3WFX(q~kB2I`if`<#6Pr1e&U@!@_?l{?Lim*->-4PH@M*!{xL0h5fJRV$F&AyRms>^4Qwd(G zL|B|7L9sv2Z}ZQv)-Hp&D3(}(lz?E;!@;X781NoEdCrBkiOXP-1&el@+uwIpztZ>> zYICE)<_M(z@31M#KgEsSZWCJsQIw{30DM3F`ww)e`p8%zI3;_$f0r-sw0io$g(tX_ z%V{HXxKiBS>4Le<92E{ofl0hZ3S{#GJoze&+@ndf;-%z0k(ZJg}iR37qQ1HtxnE1}FZ}|94yRcI`BK!PK zXvHmV2i(4`TfdcCyx5D4&t3P*tww9>#qK9}uViSpnWtUW{dG^+XK@oRd5`8#@xp-0 z%eGJEJkhu~MM__e1M-AY!`}2+ABg{kETX#Jb{Y2VY4N_MDvXhukCWX-_wI zZI^c;G|z5ly``g~iA&0CZ-iF-mYP>}@KR?{(={xn;=>q$Wj;S_Z%%EzrkF>)vN{n_WjF+pAWkm)el#{5iMe{oZGD z9BDQ*PwQkKakb8S4^px~iLzEgHe&LpUi#JaKqG0cm}}q34JTI$Tyd4&n;Mw^<|{NJ zRHuQ{L=d6kEM5MssgX?uS_=8n?Pjhr0kMh)hH}?!=Fu%N zZ;^Qp0{FD1flUDNjPO)qF@oN%JmwbDfjDN3%cG9r?d=JK?q#nB&oZP>0aWask> zqs35z13*U%ndIM4Vr^^8m9kjYpAjtlc`;CtaVCP|UkrcEUg^e6n55cPayW``V-WRy z#<;!ebquV5LZ$-T!x#qLJcaqcBj){K{?T{%u-rs|+M6+Kj-XlEU|QI?;23KXrHa?Z`YYOSX7~0vn6;0#fivmpP_PGf99Napoc+d95TS^Bo&_Oxrl( zQY^hEqeSk1$)q3xJ$mhP*ipxwblMqbopZrO{RUxZ1P&G+zE4hK0)asM~!maa3^~Sr=5fJ8)k3@ zt43XTE{u;lIXnL$=n&78&Wn`(1=brB%_$c8Oz2us5uZqALifH&fZGJM9lj7DnS(40 zAhy8eCJx%2L(;t*c7)$}+O*YYTcOeyS0wO_zP~C@qwWsUz3hu3RNM-}EWTpHYyToB zf2x=)=0MhB6eOf4HF&^fw#)VKU-e7)`_)PhEuoUhx{4j3e&c@FUO>*Og3AqgzW;R^S_HC%e6R zU)wR=5mqP9FY{7=Za0`k1e)uJMD>no@YMeER_zo@{oW-+I*kT!| z#@VIp$Aw%)LqTQKwc~_}hKostSVb_r5!rsjk|Bo6G@hx(H%%Dx3{RyUW@9^1rX0Z^ z90~?ZSO{Pvge49`NXQxT8&f(2m^|_kO-wtTv@A3|Z!i6!2$|XAiotc}y>lNJ#f^BX zy$uS2LVrDv;9jC8?{vRBzOie4i1W_#_3k^H76IL~&drA|!z+Tw>($V}@o|R&g*oa}4!} ztu`Ms6vcN#3gi@L;d4b>0Ig%SlDM?hG{dx2FMc>K>H5SKcfmn-A9!V9UA(-kav;>s z_$&Juo^&f825S;6iG@LOP>We=pK$i;{;ibA)w2Ceh8iS=#93e`NRE!vP}9K9&t*5F zEPshQd!;(65GRl8dmn8gKQWfn{t^X*bJ)1xvfO!MUwJ~GD3g&!s&k_iS;q)7KiP-7 zSE^1=WVN2BMzDddT}>%_J3Xx}cVQ4Y-xWG3@6C(Akp5*M5S)ND5k5geBJCoX>P4Q;JcKD>7KfU?F21TzH7!;{s3Pe?c`& zvy$1FV;XZ!Zoa83G}M>`%=IF50QvptmR6mSuz&HktZAhHa%b`C9!r=oWfB<6< zJE{;4q7f2hz@3yByLe$H7B&u)B3w=K^Up0p0NcjGyY7C@x!ZZR)8B;Se?0~M`9}J# zB^7mMRrd^Pxtrk}Q4T&;XjI`NfPH@Utvi@;8RE{d&EuQp5^_Y7)6mkI6+P|OOVY_Fkr%h4JW0D%xzc*({K<$BvC{YLmUZF zQcf}{q*gsG^!8~Vx=2v41XC5*X+fA4+#DjlC!Cnu(*pny3k4KXL@_0l&fU{7z0J!O zcr^@oyb82wyHi%(b6%ynHJd7(Se0A6DW^Hqs$Dg{LxUC_dJGsftV_ck5kwM2G%=}0 z8mF2dB#}%CsZl{vAw?8ZLTOZ&Q%?hp2W-k{E3JK1I(HJmb!h#JaXNyLo(P%V0 z1`$LOMKm$Qkw6m3q>x%==49zL&0}3v2kI=XcQnwbO?z5sqg^}KzDpfeS*)MR+yT zrFTQed!HU53hNFD6^$kFB$7|<>Z}te>U@7&Fig018MrV~1aJs97<|BH;e3Spk1+jz z&?0je#{dHD!7Ydv6O@}sHYK!4+6A!uUp9|rdD*Ex!ya3$X1UeObD71rw4G_@w8t%i zOfWUm{xh4*>a;oS%_Z}gUoGgOg{q5?W`OJqK@bj#p+vPXogYheqb)3(72f+`kif?x z2$-SM*h{yjXi|#zQWP$K__1HLe1;RV)qM16*&05_v~x)uwhe(j|KM17+(Ub;LiN`` z82FceFQviuxG@3>{1fDL6k?D)!$p+9q*hD8;0F7(U@GC>sh7Zl|MG;FwQH9RL8lkL z-xc|S7+N-@c=tp?I4=>+7%ocJ&(GEglL^TSu5+ZC{6=ZX<5&Qy^wg&jJ)sRM>D(RXm!-5HLO$p`~6T z>6L)r?_X6mz6=Y6cLOsrPv;K>L?m0|3*;WzrvFq#`E&{r)N{HmIKG~Xm|`$)Icd;LEVS_8-t|Z2h5>nCA&@$5boIwCze*9T@s1_+qxM&fg z#E6r6TKg|O#YB@#mLN~D0)LDqJFNc)e}`VAeDz zaF9xrpG7VM50PgYaKMoii)ZRx3}{<-PVpOElzm2eB;27m!GWM14&oJg41Wvk9}I@) zf!<}VjpL4+H)v3Mk0LZa_#D>-H$&rS9afKXnGrX^sS!my3Z<;LNK0Ld!kQ7gGJnw} z78LznzM};5!N$OlWcAg$Sk>vxOpB*RvB#@1P!P>}C|dEf@UV#m6(~(#{h+BSv}9q@ zL7PGphEr*2^kcNw&6Yc!CmI}Gx79wwjU;)aSu?lHT{$XppLo+Poar%R`p`8w)5G|k zKknjsqU+j8E^2dnmeSx{C#WSXC2+J3e-U~rMPsw(hWR7QqzsYIMn3^1GqK8)ylh-B zAlYe<&SG%aj4e9Sa}Jcx!IDvFX(X!Sca^C-6lxgXQ@Z+{dKk|(jBPCumKdYd(%>4Q zB_x`hZAz%)Ok#`>{)x#|>Ck>O=NX5@m+MOrml|gV{WPM|l(lGe^o%}37lqD*Lg^KH zY^y+iC8^#PiViHRvImqbADc%`Eyqvo-6!FjOrsRCySf0{ACQ|VuKg8Ed{JgEC?*57 ze*-z0hEB(zcY?)vPKkrewL?qO#9rgZok;oaHKDdH6Y{}!ggS{>P^n2;3^dj2 z2(@hNUQ51frJ4QhT(6_j=ce-~RS~P+WvqKP;If5^Yn)2jWz2~KvSjdTY;HGwWi@+j z$z7m0bRqUwb0kwq;^0hLlXNnIW-XgRa*X~faO z_>Sz?WMkx7vdmwUGNVNl?2NAIe9nT2dP^G3j#mw;CncKxafeL#P4`6$G)KRJlzL3Z zkx9Dc>;EhM<=^R_!Jg9cr=WIg*-(nq$DTawJl}J<#S&Iu)iP>Q5au7W!9eB3i~x3< z(e+9B`k>$s3_HPrA?p>L=-X3lg~s?|)PcU4KBNG{68{SM-CPR>h2-Y9BME$ckLS+` zj9xT-Hwq2j_(%O&exjra3o(fd*wAG@Lmk|9b~9Yr@Am9@bKrydcql1n0UkuZBQ`?2 ziEUGd52D!6T=@*{DBpt8_c5XChUg0Bc-E#{!&*XmUW@@P3XSFynKhDk< zrl2R@a?#~#Zxf{H13j?Pd-N(4T$D=%U36WoDC(toktK?oZiAys>zYeQ?g@3C9G2|E zV3u-PRk}Xv8`R#q&+!>qwTS+t9`!)ql^qXqT>#V^2Ah-d!Su~cpCwS{u_qRVI-a=Y zKRLA=Q#s1vM>+|&3G6IG_TMyeBAp#1A~$xacO+!>nf$@9u1_I{8&dt__~8 zES6Q&=Qr7wh#l?z-w90-j7cDcVSvLjwB9weEVLDCbH>L9E#OJ9_Je-1HWAZ0tk^e* z2(f6NQbz$S&DGZ}8iypY)J%A_C6;0^X#3Vfm-@ZV5-@dE|H)t-q@zl?{k`bNXaoZAPXF{*=`?JDlggX*GgB^S8(kQ`w2SefB(-QiGD-V3a8V;3MEaxg}x?< zkhLslE1=~OzmJ7~#oOfz_ldpD!+$2D21^(^lN)4#Q!Lx$3a846*3PisV6!~tn}SF* z`4nu~dGGBYcia#29SZx7U>gj$p^+uNlkp#jq8EuoBA?Y@L6-E&{mNVEC%g98Q}vNc zuv1c}ut9cm>SPvFfjJNcvE@uIw`SBhR@2Vg_lM3uDyV*7c{_XIPTIKn`#hs#9-^=8&evnuM-0w!*}=?09oKL zg_==eOb7efxI-N8Z_qt&K`5q*4YF0S-Rix(zj$6)o*pn(iSR zyVLNW{ky!H1AnB5ns4=OWX-9FfExIB`H9n;(Z)x=5u05h9pzIhr#BF^M1|U)ucd{6#pv#}Bc%0K+42B9EEs z-R%8>`Ke6aa&`oqB)+>@9XM;kGW|~=mY9SSx5Wv4mn`GgEcCZFc)&rU+RI?GqSCvLyOad>k`a%>2TDs*P(2S3Ggd z>6J2wqIN+F=j6m2$m8N0v4vOX?{7KTY5uAv>r)0~+rS45h}s+moqnD?W4!V~3N2-d zP=VjzEY?Y_w&^LYv&`o0CC@jLy5jvB!Q1b{Z0;-W5S!tLQ)sJS?=FNI^Y6VqX)?a5 z_RfFXxxe}j|LNx-@>w?8)9M3AGNeybmugF7z zV+d+81LG|zE`IQEFt+if(L|fg|IXTq*czPK_`+oNxVykMV78F#D$BJF-mHdoz+yRr zKT}GN3upt5#w?jknTyL{55zN2icd4!EW2I1{WgC$_vv z3nE7WXb;^}scvWRJ!|uk50M-LNzM;W=Ts+n{v;yTMc%}hgD2XQReW@|Ey%*;HI7^j zb(BhHNrd_#v<9jA7ZYE=cto%w>MB(!Z6F(@bE%7zh$Y69bzQ5wC7)m%;ES}iGf%H? z(viA4Hsl*~*yrMbFasf0LItU#NMn6N+BfzrEc^wbF_46s+utxk7B-Fxa=~=bTZFzv zXwq*4w~gD7o6m4iEbWVvZz=|)S3oSf)hxYmVO@|m-x6=)KWjOb3-YG#OyerOPrQ{J zh>(t*&YkDZz5j^Nk0&$&Mw2fj-E)Ow}8GDJ8_ zC@<-}Wh<{}byt{baq*~DYpb~R*|!Sg$GEMk%qoTAHhg|C{INx0I->HzcnvxvFUZJf zkY^Og4tmoB%))QDkn zX}mNwIxZ%4%;?W0F|kPyY!8se$x{0$4qB_X#)c_l>e#x-a_yiEF@wxj*x9Dr#O1*n z*>m6UEa=R!`1++%p9&&Yi&Nt}B!?1rLKg4pH%fpbdMB9@7V_`-p`j^7;OB zBgHXYbJg8)B{3tTcQisG?3Sa3VFw1ST!5_@dEPbRe6^oP7`JuJ!Oh$^L9lF|#`$x9D}|k%-J*_*}hjWU9qt+WrrRGc}V(t!{kR zsNP9x?oYmW=A(C82euyW*wk^j6~xmU>|>Xca5CumM@jU=%L^4F*A6ldTETA{H%@$%NMdguD(y;n`B5Bwiwv#y3uD@f`38qvNGJ{ED=K zX7MIK;97h$p})+O>F)fnGvFyGS-)UhHcBSnhawPIN1*|uT*%XMr0=0|t#jK6nJck9-7MMag;pBAh8a8ao`h^4Nlr%M|s6iSeC zCQ7;|x<>z1x-(sji*8UTxeI}|f!?c!KgZ{klw6MXy?RGi39R6o#8wuZhOw93Yuu0BuzdHRRp!R9!`ZP>$$g{yCZ8Jpi$*QH zAkK;xrV8nYjrZqd?xkSRMY8&*UA0yDOGVk?IieIic!5ZS!2fCrv2S9%pnjqcCgJ^l z9+?km?V4Zv|H9X%c4+KaCNhg)x-nlm?A_$|QO4wV^WLeI$zus9%riZ5#|i9TO=h%F z`ncZt!o9Golsyd7&g`!f@0#zP$7%s}GMioi6c7pn7*Hq;9>iQ-+_+!5=uOuv0#}Cw zCesYQW5FVDb&(i;bUR64L@`o()ZsT-`G3vO5$ z4Sb6K#m`xmT}p#@m)6W+XA~36FQZrHXB(W`GYBfY1*v4SwPOh}KWw^gd6?fD*znyD z^l09w$DhFVh`SzS$tILca z^^%|_=^7dFy47x+xMaFQB7`3cJl0>F<2~?Vs^j>`5kF_E7vVkY?Bc!-orqN&nNk4aGGN$MwvT{HEg-9O@m_Q_k99WK zcNxpVo;S~t^(B|WfnD(|UM*u}HNAbP3ln7@T@IX5b89rKf6+|w!EM$ADeU z0;)nlBwR9|EPE-lkvLoF(xjYRP2Q3nup-HZS=@)1yk-LDS<5S)H#Y6$Pi~`)e&ZI@ zft|yIh@ExBE4X^ZF_UoOOOpi@5b&>P3}HwQuWf(<)0DuW5evY(5r^XtgRyZKzMse8 zLT&FQ(#Me^Wsjri_$ohE;2Mo7s%7R`Z&0XS*;*Tm_0XV|`c2lTSISc=t0Ylu@>Qsp zFHNyVg>2kqDpsXTq#BiqOj4{K|422e(Y$%^dW?e68nq;9lhc~0WwthkQm0wz&i?eu z51lB{648RoCZk)bsoXhVJ))Q9lcqIF%UW$#C`;KivL;>{IO~KR#p_5_PgAc(Iea0` zM^w+-Ea)3<+7>Swcv-3}wcDgX>V;}l@ud2?s*tzh)oMFx7<+RutGwh}MZW?j>Jyhvh`N*I6)Mwmz@Cf5FsMZ4n`r0=`@$Ktw=eM{kMD%NFfBfaDRp}AKsz$6aacb3TP$yo3L`lBWs9B39 z$rB1GQYQ@3q-&L-O@~hHdUZK2(*qMsG|3{9P0?+pS!S3j%QV^UxnR0smu$10Ju!#Z z3+J!4{egr+48%k%#6}#%MLfhu0wj!`e$OI`z2{y;9EUi@i5&q~G6xZMQP}B<1$ZbT zVK}&Q;pN-(N-o369PTbKf>131?z*tBGqlllzB)9l?pr!Wu4d%7Wq z*LVMX##SV!JmY@nRHYxy&F+%-vlr%GT+EfxU7Yk22S>s`{c$Ax3lQ^4o-q&1Y!})$ u2LG3&q^T;!yI{mCY0#1vF7D?t>nI^_mQAs{T*VmfLS5XQbg`eBm|g-5u#f@UFTmx~SLV)7Vln8BETkXo`l`Y#A)Y-Pq=B;j6-ZoFs#<~?0 z{lRZ3Ff`Mk1_W1vgd-$z$-~TgBQc#d+z-bat6n)H^i9^h7r+@!} z-}jqew;EWv1!I2xBdgz#WaZZ))mtLXF*Ml}kUJ>I{UV|sHlzHT?6fGnZh z`3hM*(!d#E=?7GbNv{`^K`LTmGDvs#<_9r_U8$xmPWaOh%J_FmRODBtiq5cem)a0Z zy9XBWaKJNb#HYe%6MP4P;S;SuI#HCvOU|*3iLZ!v457odM`R|v`l7DFJ zp)fYyZBfZp3~DD16tXlSv6exV?)OcbO(@R~)nbt`0*OTV%b(5eP3D$b6smQd!cwa{ zg)_*LXcRnrEuDU$={kOKaUdkhVdN$K-(AKpW&94RREdvJ7=iGus2Ghgv0AH|ta7F8 z8!}wH00}IOd3<(#^*xx3fKgZA%qY_ib4vJLZlGq z*j>ivyLl9q=1}fmk^qJPw1SvLg8~T!!lRJa)(~bXgqI|U6uA)jsvv6ALrAniwCjMn z^PC-V*FA_y9x>|>%$a}ki(YTo2+%^BJqXi2_*oF}R;FkG=ik+3HNb!Y%D+MpQK|<= zV937>R7j}XzGV%N2pEIC0@o+57lf1m2}3+>LJ{6d5Jh+j`gE?%`lOS+jmPO_s7;6s_)+v+PxeUnAMfMV-QCgt zmIOSGE_?S?)y_6o*1C$0_dk@qa^-t?;AF?xnpcih7cJLonyqPfOy+imhon{O=^(qgF1 z=VY!K@{N~km;rk0Ca#^91+<;u7EG-!@_#<$Rp#Q|r>wjeS6c3L@7cM~{N7-iZe*I(TA)1}=dtt{{Dq0T z%%dahSg^5+4FvBdo+y{w60ZiYJoH+h*r`GMf`co;;qZ8I+)>M_PCUeCtS}}{T&lfN zia6UiCkfJ&DO(k{&@}Z(ic{36A|U-}{pZ#c351jg;CgdB)AxIWY3c#{`bKw3P`N<- zJ@Vk6lxpEo!NZdn)kP&kMlySxoQKS0nU=Mej5c!_vHdnGZ0a#Hi(MtsS^?a|$yD}C zBz&h-(stzk7ZV|fFAvU`teHV#nL4`zL~~gqQcPGpHMO9NL>qwhv;%A#9Aq-2n4 zGT)}iE|?xO;~t4rAqNk9Ne9A0(bV+lb|K1IrR#9(&8l+CZN+q$3+-|2p{h|)emhY` zN(AQ0M3?7_q2RWKV)ZN+`Q+*f%HJ2S1-R*!Kqz++g)m{lm`zn~QUsZ0D6fnZ1SLKrTvfqTV;)hX7DgNbeI#TQRK{pbP(vq(h`2`l zqL~{QXdHXt!NA^5RxA{~QC_-Nj?~3^*%b#S^YMgCD6XUPK?W64>KYBWD+m@;{}O(@ zU2tkSxqatrgRodA0xw{zfxN@<$0JDW8aXG5%EeB-+zmm&qs7g5YaEV*IN%k2jjbai zcfq{_6`<6TTN!i5+aWwsKqt=TtDm@=U48&B}g^%)10MW;L zrJhz+WsP!DPycg}#hjoz5_GsVwBqU*0@itPA$TYJx&0J3-8`i15^fRZvTW9i7LAYb zd~KX0n;aJ1b_?`CB>JGW1&}STQ}oo5XY8<|)8R0(SER`-(^cBZ5RG$V^Z}U7Q<~ zVPyti_3SZGF;OusVZe|XBQ^}<@B?b1SnbPHPq|W+BXv2`6iaV887^!4%F>CrYz3?) zJ3182m7=>^G+%_idS9kP(D_3dL-1|n4fLo90!TGPq!l>aI6;{{?^00F(DjKK15Sp1 zSs@G<_DaHZX33M=wc_!|T=<4?yW_sH&>-Ek9f#&Zbh(g&(Vo8oVjB$JneoJ=>-r4g zMR82ye+`(h#Nx;mn;Ul?mh!?8B21ikiTF~b5lFXOscJQ(8Y#8?MBhE=$fBq)G)gpx zkR&2bObCUi#)PII6np#P3zHjacro5>gl2`OhzwGwIfG;=qiD|1xo%s;!jOQC9HtYFia)N zRS>EqQgwB>xP3w08KE01rXh4eOSEcG>kZI42}_YF%Q4jJ!^WJZP;<;KjFc-jH|{(v zji^h(RncO^;)&~X6p1mC1z)N(f>fSRL8y{QRa%)NBd6HknATE8>tp8li&j8JK}AC! zlBFn$q9_0W0HA_I$@o&G5tPiF{5WqZ>C%v~vK2BVr-;UqQj6ATPn!;Q?AyP{oJd2S z4;f>`0!RolQBcv)7Ym7mWkuvD3x->>k3;xd5(!omW@gy?Y&T(y7lqEy9#-U%IW0D^ z8O#-yL9;YpjHoO9ued30&2L1hKe*8-Pbu>)J+z3hVlee2Br#;d`{S6a!bq9T7}mIi zpY$ib@t+Y0p;;Xd8?@^gTiAZ6?Krq8fcclqQ4h33oieXNfyEAzY>(mYmx>5DO%}K zhaLN(SPCJ9myH+@7`08!Q3gmletn^Ks7we%ddcb)h>}HeW5)!XzZby?nhWnKMj0Xb z%+sMnRatgm?L9C!TV`l>7vMpAm!>;y^KJ;D)la0y8f%e&S;pDbkxKlLyNPbr3;bFc&a4tv6P}nFm0V zu;+$%VCuINmSHg9GhzCF64X94=!qa(0pe$MKpPayv`z>R9?O0*$@4_;(YEFzRHt)3 zHTfk|owxnLpNbGL0+rzOWUy>mOcu^^W%;whSUlFl8+Y7%Z+*;gi%&^eC{V01BQ&5vSh`Y4JHRHI43T=`0(Y2!(WI{VZtqQ)=B3)b;$>j;w4Cu zBHeNsGUX{ys91?IHN@)FlaOlEM5$Su)BbSAw{AP^jxSA^^rz?Eo7QREcP{wZN5A^U z7F)gX%u#P`w#qewb{cX71rZ4i85Nxo3ueqQ*t4~S9TU#nxnf(&%_AOy1qc)*g0}`S zqHysUsMX3-wHId0dgZDwT&8n{76K{% z9svIW;!nW;pv5~tn|(lSG?2#tBmi0TBM<=sfkGiKG)}*3PK`QvoNAxS<+`s_Z}UQT ziXyZM^=F+Pmqkw4Ygc)Zjh@R89^f=6_}V3C7)v#)KTj3%0i%>C0KE|khmfF?(b1}j zwEZXo=4qa4Wm{IhbhB!#2M~duS@-hMyUPbb$wFjgSx1^%!EDM#)sd^sPs^P~@o5Yq zS^_(k1lX1^mg-c%U^mgZoxWXBo@g7?G>-6_oi}@tb&X0lySafQFpA%>8+{f=5UdZ{ z07zZe!H{v59=1)q%)VuUpqFe`oJm|VA)WStlbV_*UdxTlrGOW~@N-KML zU_R0nmK41!cPP%pZ1fxo+bwq-M=IoKD;-U+#Y8VuG;vAgs#CCye42fn$&x{I5?&69!{aC%sIMvVaY7pu+wPhD^L;x3!&(# zP9Oktb`xza5^$=)OzT7Sl&fG)FEn52Cdlw(W$cI;YdNe;eq7 zlyVj4sBp!_RVEyPMws9j-6hQ%zrswXl_{O9PAsD2Y&7Cj{V|QpP;;ix;96{^yMA56 z%rl>LP{=PQx-Xp(_n;~iAI!vSKGSI?n~wZq$&Cd%^VlXmijnyxes+|021s{6GOr38 zLBYUxx%gbg_#PFr)1^^NI_|yS5oWjF71+x{XK47F26p;t9l0l3B zqZVcv&VB*NS3@&+s^<~Ym_z+Ce(KQt*WCV9&z(&)T@-t4-Ymki#hD#&+Cc4dF;!`o zaUEcGV-2y_u=$LiS+QO==gy@lT#@r*%2XyQ#OS0nUB@opIfH9{^Mo&ewQi~;`$jZQED_41y83Xl_G^&CY)v14rR$aYp zy2D!lUgjUBPTs=ps_b~&ry(ts^C$}nv);rtmm#Ute#N8~dt~yeo)YeFxq_8vr3@Mr zMEJv6G*gnDqiCLGz=ud#s!7mifnCpmE}OMJyQ@xni?o5YtP&EMwqwEH%LM-J;kLn` zK+(D#NpRK)IioR*@nON=%L)yQT@{D|GUX|+Wt7ESifd+8rzdecqGLWC_-qjmN#*<{ zbEqG7i?mf}OYPf>qNPow8%)+4x1e;L%bHCfI)^okx^-*#mB_#|bfBBG(bijX9~hIG zM;nwP-`8Zy5yqXtC(`R~Mp-<>V?tsgD^$Luln_|sO%q@y?z!x?!M9gT(h4|Z? z(@l6+VBab*&+*cNQhnD|D36U-afPCj0srNE)NlHyq|~+R2>RU-cGj5!LL**9OCwG!`9!A4<5b5UZ484`%R6ovR)91qfM^Gl!a-vPMcx&32^Bc1InR^~ zp)c3kqhH~Q_zTGE+zSkYPzx8zZ|9g^4}66It58^7L$p+eqSF9FtY*gt>g0I*eP?ni zFYw&u6^?O&cTi%U7rNRlSutg5iUBWGc|3@6WR}}Qt;tkS2!Z) zzv&NrAeO4J_P#`HM`QOfjZ)C(5-VxV?0E~8hcUhDFxkIr?30%)OXEr^ENj}Ts#?8C zz`;LZW#=oIcvSjD-kpJofzrSN_X5DQ?Vq%LA%pM2r?PL5R!Bv+RPc{Go}@?`6*cM4 zK1w~aF+^L|G|zH$>|g;`d$FS8W^;VPMX~sSbccT(K7W?rJW@V_jX{v?TbV3JDiv!& z^%HmlKA{iQh_{!|ftlyir?KX7fN8G($>b)jt+v9!hNqATWdm0`u2fEHa=cw`&Ak2E zNbJi1=bEM(eBAhO;XXOoG&uSeKQz+ca@pzZmD%TH^1tQyi}LF0A@UH0#)F1J^dS2Y z)bCK(nwKR1$S4{YHy4dgVOVBR-bShd0niH#&7aBj|8K*4_kkq^FYI8vFzLV~_6M^*#DSkLg&o>})y& zK%TM#O6&aJa+D7Br5%&E7YY;g|6!(OqNn&WH|v_Ir#PtMju$8jMw^?SazBO3=0|BG zo;H2*or;8fe;l>qx8gc)y{5SYKZw7aRsQsKkW!=5D1B_Vxpn=7!$5Fm=K6KUG9P-9 zlBCI_fucWWGY*2Wu|DaWYwYE2F9_yPItsTYT zQS|m}SM~jcZTM>ye5ihrG4R>%K4{=Y&THYo=kMwazz6#epKU+PMAg_XBQiCBi$y=8 zMKY-CA61o%2A&17f`b2|^gEu}b%)l95!nc+l?h4lSI<=@@Emm`91R%VkAf|FrYgFo z2IWRaN6#0qkN!_~_PaXqSWdxL;jkHcc3pE`-CQn&i;GqEiM$Jpoq zd7E>2YmM_$v>a@6Sd<%XO z2fs>ZQI$u^9IIcYjT({+^r*o8rBGaJ zTv)(UF`b6YHt@K*unKO*$MUGhEkC#V^b82Ut8(G<_ck>2s7lOoic4@61m$t3;-{GZ zLiI`oaWALZXZ>^A<#?#7thV+PaH|$J7&LfqTG4r7%b|r+$d+~Yvc6OSH?Mn71>rAS zGt!rL#`63FpR5(DRV8Lwywt9;#fba@S4vlAM#etFY?fS1F{bG3nySYi;d|H)Z_f5+ zIgCH+lS&q_PY!14Hz%)I_-1le|4omJw!R`w0H272!eMrMkd7XGH>LP?sg+r;8 z9x&LMl$dy(?se@ARmF?NC-Ien^wzZa`c=54GMyWRcgBCMFNqQcFs(V$X zrs-A>mg-e&c6QbQc-1S<$Kcg_^<@{Bzf^~WVtJv>8G~nJsJiQATY&&86SP57{my&P z_Dlbsd$YZIbME%D@5cTHM*V0Y6>Krvk(6|(d`4MOSndGUl?*0Ie|ACicc6uM9K2QC zSGsfXN#;xQ%Y;H*!)k3rGzirB<68b!>DE?8@~W){{2(tb{+P>@aKK#VlAf+zzMNj} z)6lKXjIxqXEtvxR__fs3y8(<>f3cI&Dc4`=4?*&;F3>#bbduD!qy4o-RONbNXX zR#nN!1lSJ=8mU-pCl>E252sbh-T*Fc(8R$xSovu}G4bhzlMttAcsI3Ob;Pg9 zd*BTPt226oMi1Q1R%CZ;h%Y(V11BT!6w5S8a0d3k^6EK-sEb@zw;&dKLXh}3yEwd& zLQN<>iXS@iqged2Sj>4P$JgN-6O$`&D37ETE9Sba!~4Ncd{xgoif8gmqY$3 z2k<=evFOS_oB#H&_K)eLA6SbPc2WROh;Cmpl`+Mc`gSp2IPJDGJ-sD0brQbYi`}iw zFFg_42Y6D+kF7Z=DNmA9a%#UU8QsqYktvs{Ty0h~Jmo$m%mP1czgDC5p_Mz5G!!|M z1XY$J%Tq`hsts9_25e!ZCZQ#wCBecmMv7dlNJBP=X&+Ms+)(dBP0co0uIWNz zGUdlvG|iQ5J0@l9;+O@oNk0(taU{UZeJL{4HI@#{gacX`dxap zt`-EGTlMv#23bR$_>V`*?_%uIO+pcpx*{w7d=hE_D^I-*j?e> zB*giB>6Xbv`NI4#*bV$@RjiaP-IB7xvq)Mr*3t2&M3STO;mPsM^V0e7giH$2Dw)_w+1Z&Wdoulg&ZO zNF^dq+%1WtYb)Xzw3Bev8`!Hmc9S}`bvsPU9ADv}S+8oda?4d^j!N~wT z;+RC!snvv@%5KjNHKJ`v?0=rzS3Cp(t{@%%mi)kz_ zK2uWCYU*7~_nF?I(Y)NU;^MVJr989#I#0KVLX~&ku3bt|v~n*?fIS{IKCT!)P4g;ydS(K( z#As3bM>Y-4c zhm(6GPtmAdqV@NO-rVwv@QRARn?4pcrVk+*Ly98lIRaO~j#3cLNr7!CO-pMBpff9L z#QVM&vbE}EzcyV)fB2R10W(ii+p2NOk-)vi#Dq z1QK>~vR+$h@zV6s!O^W*wOJ6Fmm6cJb=9(~crh{RP?(b(?A8dyvyIzs{{`FY-CFFl z;t%Z#u)Cw>m_}$B#6@Ao%eRkY7k53v=LiPCfQI?Sa<3xP3ulFM!Ueo&o9b2bAk+(H zC9o;1&3WhbL8uqb;yIS(Ke{ZKwo@Ez5q7CAhEOk@70%&B)?YX;)C*^u=2mTohp@{~ zB&o1D4hZ$a*}c!rZ`lRlMLR{WnoUBza2C(8S$m(S5$c7rc+M)E<}EGsg-TFGy{VyW z_}odT7tRXj@S-hMsCI!+FPN3UI#oAMf=2ADyP*%nqlnkx^>`z@$-(XTgT8h1C-4i{ zdv;()wjhd~&CCE>KEMfg(|9I1ZLjl=s%X7X~I+ec z{R{lpr|Gwdlt;$Y4-c~HNApjB!!>gWdj{tR$$Ga7HU9A=y{+3r0jm9d#>q0F^ z$AcIQ?v{-Ct$L|$ z@wFPKFp*%u>)@as3h=d-Q;>&Z3>5Abjk&FW`wC)$?|yP#MA6P;?-3z(cP-op_hI{# z*{E(vq+_ZGzE*XLdx~fQ2@Ye{yt-o82$xpGL~D@~T8E5L%?vkCZO41?S9mghfKTI3 z@h9vFKjd9@1W&rXa$JjzvtwMg`!*~Bz;W9jv9a{Ok?cL?&b_%e^}_E30t6WJ_0fis zs}X+`GGPYbjlZSe$W#B%q5k*zz1pQc2SJ7c1eiO__^9FY-35TSThqUpNkxbJbCc$fA= zM{xB1NaTgJ614&Y$N>%RfOCl$r>-(RP#?5Pg3Yamn5{r^L@mPUqVR&Ze#zw$ao%u% z@=?t$By_GHW0yOFh?x+KhIsB4(d*RDIb0sO;C7k>(Hp~#o+2VHeU~`MD#dw5x~36u zGU03>{+ZS|-6o>92jdhF43GT}2(eE=S~A+cEUM1oH@hTMr?Kp=7uqjgE*}tB5j#nwbjWKY@Pw!o;~!vm~u}I zy3d^8UJM9bn2{Jgrn;Kp=DT}a&5VdGkAscuj?5QK5qI}@paEp}En_*+e&c9?DMpf; zqFgd+L!+pJEUcx!0KVqBONr?sKC{qlzi=4hcUve7QFXBO+ME~3Pg|S z3AUGfKbV*3TdaGByK)+cASEM%D}xmH=Y(biif`@0SYWwf}-hKoK~*>yC0KR5+=h3s*vHFSw~R;hry-nR;MPlkR)q zAury1Mwo)9Cc^Q^V^zMg*(sy^&Ci|c4i@5T-@NQ^=eRA7@RwzHbJPC(Yc;4hEKWR$ z1hW!J$;hpgBv}f+?Pi!5o0w?}oJuk(h}>XP{WANL(hf=8Usq+;q#iJsw%g z2G2Ydx)EaG2bhBIv9xSHAL9!p3UXsZjW5SP7^^FFb9_Vm1aq)I4#0spcudH)zP>S` zn4^Z+!M+W_^Zf$-sW*TsX{cPIVyxO=t%XW4Q%)DvG*N@KHLAyoW+Ix$QD1Bqy7GL?YglB3L>Ngc8L%6rlvL`0ok; literal 0 HcmV?d00001 diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b2328fb..990dd83 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -13,9 +13,42 @@ exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the spacing/type/shadow scales) for new work. - Offline appliance: NO webfont @import (no network at runtime). Goldplay (the - TRM display face) is not self-hosted yet — display/heading text falls back to - a clean sans stack; wire local Goldplay @font-face here if it's wanted. */ + Offline appliance: NO webfont @import (no network at runtime). The primary + face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL, + license alongside the files) — latin subset only (covers en + sq ë/ç), the + weights the UI actually uses (400/600/700 + 400 italic). Not a true + monospace: it stays FIRST in --font-mono for the look, with the real mono + stack behind it as fallback; .num/.tabular still request tabular figures. */ + +@font-face { + font-family: "Chakra Petch"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2"); +} +@font-face { + font-family: "Chakra Petch"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2"); +} +@font-face { + font-family: "Chakra Petch"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2"); +} +@font-face { + font-family: "Chakra Petch"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2"); +} + @theme { /* ============================================================ TERMINAL ACCENTS — aligned onto TRM's exact values. @@ -92,13 +125,15 @@ --color-viz-8: #5a5a53; /* ---------- TYPE — families ---------- */ - /* Mono is the booth's primary face (data-dense, tabular). Display/UI fall - back to a clean sans (Goldplay not self-hosted — see header note). */ - --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", - "Menlo", "Consolas", monospace; - --font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif; - --font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif; - --font-body: "Inter", "Helvetica Neue", Arial, sans-serif; + /* Chakra Petch (self-hosted, see @font-face above) is the booth's primary + face everywhere — it leads every stack so headings, body, and the + `font-mono` chrome all render with it; the stacks behind it are the + pre-2026-07-05 fallbacks for glyphs outside the latin subset. */ + --font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace, + "SFMono-Regular", "Menlo", "Consolas", monospace; + --font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif; + --font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif; + --font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif; /* ---------- TYPE — scale (TRM, optimised for data density) ---------- */ --text-overline: 11px; From 1de209be481d7936a4e4d71d982a2551e81d88d5 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 15:23:29 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(shifts):=20operator=20filter=20?= =?UTF-8?q?=E2=80=94=20select=20over=20real=20operators,=20no=20more=20foc?= =?UTF-8?q?us=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin operator filter was a free-text input that broke three ways at once: its visibility hangs off the query response (scope === "all") and its value is part of the query key, so every keystroke started a new query, data went undefined for the round-trip, and the input UNMOUNTED mid-keystroke (lost focus, list blanking that read as a page reload). Filtering also silently failed — the server matches the operator by exact username, so partial text matched nothing. - keepPreviousData on the shifts query: previous data (and scope) stays live during refetch, so filter controls never unmount and the list never blanks on preset/filter changes. - The filter is now a setOperator(e.target.value)} placeholder={t("shifts.allOperators")} /> + {/* A select over operators that HAVE shifts — the server filter is an + exact username match, so free text could only miss. */} +
)}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5e36d46..0037c1e 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1151,6 +1151,8 @@ export interface ShiftSummary extends ShiftSourceSplit { export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{ shifts: ShiftSummary[]; scope: "all" | "self"; + /** Admin scope only: every operator that has a shift — feeds the filter dropdown. */ + operators?: string[]; }> { const qs = new URLSearchParams(); if (params.operator) qs.set("operator", params.operator); From d5ff2097bd5b4613a81c7de7837d147a300d9e8a Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 5 Jul 2026 15:23:29 +0200 Subject: [PATCH 6/6] feat(web): currency becomes a closed select (ALL / EUR / USD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currency was free text in the tariff editor (composer page + lab draft modal — shared form) and the subscription plan editor; a typo could publish an unknown code onto immutable versions. Both now offer a closed select from lib/currencies.ts. An out-of-set code already stored on an old record is appended as an extra option so it displays + round-trips unchanged. Blank tariff form defaults to ALL (was EUR) — the site's actual currency. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/web/src/SubscriptionPlansManager.tsx | 7 ++++++- apps/web/src/TariffEditorForm.tsx | 9 +++++++-- apps/web/src/lib/currencies.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/currencies.ts diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 006882c..9cf36eb 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -14,6 +14,7 @@ import { type SubscriptionPlan, } from "./api.js"; import { Modal } from "./ui/Modal.js"; +import { currencyOptions } from "./lib/currencies.js"; // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the // operator sells from (so the operator never types a price). Editing a plan PUBLISHES A @@ -348,7 +349,11 @@ export function SubscriptionPlansManager() { setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> - setForm((f) => f && { ...f, currency: e.target.value })} /> + / {t(PERIOD_KEY[form.period])} diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx index 3407375..50cd04d 100644 --- a/apps/web/src/TariffEditorForm.tsx +++ b/apps/web/src/TariffEditorForm.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { currencyOptions } from "./lib/currencies.js"; import { isTariffV2, type TariffBlock, @@ -101,7 +102,7 @@ function emptyTier(): TierForm { export function emptyForm(): FormState { return { - currency: "EUR", + currency: "ALL", gracePeriodEntryMin: "15", incrementMin: "60", lostTicket: "20.00", @@ -339,7 +340,11 @@ export function TariffEditorForm({
- set("currency", e.target.value)} maxLength={3} /> + set("gracePeriodEntryMin", e.target.value)} /> diff --git a/apps/web/src/lib/currencies.ts b/apps/web/src/lib/currencies.ts new file mode 100644 index 0000000..28416e1 --- /dev/null +++ b/apps/web/src/lib/currencies.ts @@ -0,0 +1,11 @@ +// The currencies the booth can price in (ISO 4217). Money is always stored as +// integer minor units + one of these codes; the UI offers a closed select rather +// than free text so a typo can never publish an unknown currency. +export const CURRENCIES = ["ALL", "EUR", "USD"] as const; + +/** The select options: the known set, plus the current value when it's some + * historical code outside it (so an old record still displays + round-trips). */ +export function currencyOptions(current: string): string[] { + const cur = current.trim().toUpperCase(); + return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES]; +}