Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 |
@@ -1,19 +1,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
|
||||||
// Tariff composer — the admin edits + publishes the LIVE rate card. 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
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
|
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||||
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
|
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||||
// lab (a draft only becomes real through this same publish path). See
|
// POINT — publishing always creates a new version effective now, it never edits the
|
||||||
|
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
|
||||||
|
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
|
||||||
// wiki/concepts/tariff.md.
|
// wiki/concepts/tariff.md.
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
// Which published version the editor was last loaded from (sidebar highlight).
|
||||||
|
const [loadedId, setLoadedId] = useState<string | null>(null);
|
||||||
// Optional label for the version about to be published. Deliberately NOT prefilled
|
// 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
|
// from the active version — a tweaked card republished under last season's name
|
||||||
// would mislabel the history.
|
// would mislabel the history.
|
||||||
@@ -26,10 +30,17 @@ export function TariffComposer() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setState(s);
|
setState(s);
|
||||||
setForm(formFromActive(s));
|
setForm(formFromActive(s));
|
||||||
|
setLoadedId(s.active?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function loadVersion(v: TariffVersion) {
|
||||||
|
setForm(formFromVersion(v.currency, v.structure));
|
||||||
|
setLoadedId(v.id);
|
||||||
|
setMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -41,6 +52,7 @@ export function TariffComposer() {
|
|||||||
});
|
});
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setLoadedId(fresh.active?.id ?? null);
|
||||||
setVersionName("");
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -71,6 +83,8 @@ export function TariffComposer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<TariffEditorForm form={form} onChange={setForm} />
|
<TariffEditorForm form={form} onChange={setForm} />
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
@@ -87,6 +101,50 @@ export function TariffComposer() {
|
|||||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Published history — click a version to load it into the editor. Same list
|
||||||
|
the lab's sidebar shows; here it seeds the next publish. */}
|
||||||
|
{state && state.versions.length > 0 && (
|
||||||
|
<aside className="w-full shrink-0 lg:w-72">
|
||||||
|
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||||
|
{t("tariff.versionsTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{state.versions.map((v) => {
|
||||||
|
const isActive = v.id === state.active?.id;
|
||||||
|
return (
|
||||||
|
<li key={v.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => loadVersion(v)}
|
||||||
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
|
loadedId === v.id
|
||||||
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||||
|
{t("tariff.activeBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
|
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
||||||
|
{v.currency}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ export const en: Catalog = {
|
|||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||||
|
versionsTitle: "Published versions",
|
||||||
|
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
|
||||||
|
activeBadge: "active",
|
||||||
publishedOk: "New tariff version published — it's now the active rate.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Base rate (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
|
|||||||
@@ -339,6 +339,9 @@ export const sq = {
|
|||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
publishing: "Duke publikuar…",
|
||||||
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
||||||
|
versionsTitle: "Versione të publikuara",
|
||||||
|
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
|
||||||
|
activeBadge: "aktive",
|
||||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Tarifa bazë (gjithmonë 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.",
|
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-365b648
|
TAG=stage-d905dd1
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|||||||
@@ -113,3 +113,33 @@ describe("hikvision snapshot stream selection (main vs sub)", () => {
|
|||||||
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)", () => {
|
||||||
|
// The device monitor logs + re-emits whenever the detail string changes. JPEG
|
||||||
|
// frame size differs on every frame, so an exact byte count made healthy cameras
|
||||||
|
// "change" on nearly every poll. The detail must stay identical across ordinary
|
||||||
|
// frame-size jitter and only move on a real shift (different stream/res, tiny body).
|
||||||
|
it("frames of similar size land in the same bucket", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(16_716)));
|
||||||
|
const a = await cam.healthCheck();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(17_902)));
|
||||||
|
const b = await cam.healthCheck();
|
||||||
|
expect(a).toEqual({ status: "ready", detail: "snapshot ≈16 KB" });
|
||||||
|
expect(b.detail).toBe(a.detail); // jitter does NOT change the detail
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a genuinely different size (sub vs main stream) lands in a different bucket", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(299_395)));
|
||||||
|
const big = await cam.healthCheck();
|
||||||
|
expect(big.detail).toBe("snapshot ≈256 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty-ish 200 body is flagged, not bucketed away", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "xx"));
|
||||||
|
const tiny = await cam.healthCheck();
|
||||||
|
expect(tiny.detail).toBe("snapshot <1 KB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ const SNAPSHOT_RETRY_BASE_MS = 250;
|
|||||||
|
|
||||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Coarse, STABLE size label for the health-check detail: nearest power-of-two KB
|
||||||
|
* (`≈16 KB`, `≈256 KB`). JPEG frame size varies with every frame, and the device
|
||||||
|
* monitor logs + re-emits a status whenever the detail string changes — an exact
|
||||||
|
* byte count made every healthy camera "change" on nearly every poll, spamming the
|
||||||
|
* rotated container logs. A pow-2 bucket keeps the diagnostic value (a suddenly
|
||||||
|
* tiny frame still shows) while flapping only on a real scene/stream shift. */
|
||||||
|
function sizeBucket(bytes: number): string {
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1) return "<1 KB"; // empty-ish 200 body — suspicious, worth seeing as-is
|
||||||
|
const pow = Math.round(Math.log2(kb));
|
||||||
|
return `≈${2 ** pow} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
class HttpCamera implements CameraDevice {
|
class HttpCamera implements CameraDevice {
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
readonly #port: number;
|
||||||
@@ -85,7 +98,7 @@ class HttpCamera implements CameraDevice {
|
|||||||
try {
|
try {
|
||||||
const res = await this.#get();
|
const res = await this.#get();
|
||||||
if (res.status === 200)
|
if (res.status === 200)
|
||||||
return { status: "ready", detail: `${res.body.length} bytes` };
|
return { status: "ready", detail: `snapshot ${sizeBucket(res.body.length)}` };
|
||||||
if (res.status === 401)
|
if (res.status === 401)
|
||||||
return {
|
return {
|
||||||
status: "degraded",
|
status: "degraded",
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, domain, business, pricing, research]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tariff systems in the parking industry — survey (2026-07)
|
||||||
|
|
||||||
|
Web research (2026-07-05) into the tariff/pricing structures the parking industry actually uses,
|
||||||
|
to sanity-check the [[tariff]] engine's coverage and rank the gaps. Sources at the end; legacy
|
||||||
|
cross-check in [[parksql2017-legacy-schema]].
|
||||||
|
|
||||||
|
## The taxonomy — what operators run in the field
|
||||||
|
|
||||||
|
**1. Time-based accrual (the bread and butter everywhere)**
|
||||||
|
- **Linear hourly** — €/hr **per started hour**; billing increments of 30/60 min dominate.
|
||||||
|
**Per-minute billing is rejected in practice**: garages that tried it (e.g. Leuven) rolled it
|
||||||
|
back within months — customers couldn't predict the price and complaints rose. Vindicates our
|
||||||
|
`incrementMin` default of 60.
|
||||||
|
- **Degressive ladder** — marginal price FALLS with duration (€2.50 first hour, €0.50/30min
|
||||||
|
after): the standard shape for hospitals, city-centre garages, anywhere encouraging longer
|
||||||
|
stays. Exactly our V1/V2 `blocks`.
|
||||||
|
- **Progressive ladder** — marginal price RISES with duration: rarer, used to force turnover
|
||||||
|
(short-stay curbside). Same `blocks` primitive, ascending.
|
||||||
|
- **Daily / weekly caps** — a ceiling regardless of accrual ("day max €7"). Universal in
|
||||||
|
commercial garages. Our `dailyCapMinor`.
|
||||||
|
- **Free grace period** — 15–30 min free at entry (airports: pickup/dropoff support). Our
|
||||||
|
`gracePeriodEntryMin`.
|
||||||
|
|
||||||
|
**2. Whole-stay prices (not accrual)**
|
||||||
|
- **Flat rate / day ticket** — one price for the day (Delft inner city: €29/day). Our stepped
|
||||||
|
("up-to") table with one row, or a flat V1.
|
||||||
|
- **Up-to matrices** — "stay up to N hours costs TOTAL" tables: the region's habit (legacy
|
||||||
|
ParkSQL confirms). Our `steps`.
|
||||||
|
- **Evening/overnight package** — "€8 after 18:00 until 06:00", whole window one price. VERY
|
||||||
|
common municipally. Exactly our `packageMinor` (built 2026-07-05).
|
||||||
|
- **Event rate** — flat premium price during a known event window, set in advance. Expressible
|
||||||
|
today as a date-ranged windowed card (flat or package).
|
||||||
|
|
||||||
|
**3. Entry-time-conditioned rates (the notable GAP)**
|
||||||
|
- **Early bird** — a discounted all-day flat for cars that ENTER before a cutoff (typically
|
||||||
|
09:00–10:00), sometimes with an exit-window condition too; no forgiveness for missing the
|
||||||
|
cutoff. A CBD-commuter staple worldwide. The defining trait: the rate is selected by the
|
||||||
|
ENTRY instant and governs the whole stay — which is precisely the
|
||||||
|
**pick-table-by-entry-time** design we identified as the sound future shape (also solves
|
||||||
|
weekend menus). Anti-arbitrage conditions (min stay / exit window) exist to stop short-stay
|
||||||
|
parkers grabbing the flat.
|
||||||
|
|
||||||
|
**4. Calendar windows** — day/night rates, weekday vs weekend/holiday, seasonal date ranges
|
||||||
|
(Berlin: free nights + Sundays in some zones). Our V2 windowed cards cover all of these.
|
||||||
|
|
||||||
|
**5. Category pricing** — by vehicle type (car/bus/truck) and by customer class
|
||||||
|
(resident/visitor — SKIDATA advertises residency + vehicle-type tariffs). Our `card.category` +
|
||||||
|
frozen session category.
|
||||||
|
|
||||||
|
**6. Contracts / recurring** — weekly/monthly fixed rates for commuters; steady-revenue anchor
|
||||||
|
everywhere. Ours lives outside the tariff: [[subscription]] plans.
|
||||||
|
|
||||||
|
**7. Discount overlays (validations) — the second GAP**
|
||||||
|
- **Merchant validation** — a sponsor (shop/hotel/cinema) reduces the parker's fee: mechanisms
|
||||||
|
range from stamped/barcoded tickets to one-use codes to LPR-keyed auto-validation. Discount
|
||||||
|
shapes in PARCS products: fixed amount, percentage (up to 100% comp), TIME CREDIT ("first 2h
|
||||||
|
free"), or a full re-rate to a different rate table. Fully vs partially sponsor-subsidised.
|
||||||
|
- **Channel discounts** — cheaper pre-booked/online/app rates (airports especially).
|
||||||
|
|
||||||
|
**8. Dynamic / demand-based** — price moves with occupancy. SFpark (the canonical study):
|
||||||
|
target 60–80% block occupancy, ±$0.25 adjustments, → −43% search time, −30% GHG; average rate
|
||||||
|
actually FELL. Airports run revenue-management engines inside operator-set floor/ceiling.
|
||||||
|
Almost exclusively municipal-curbside + large-airport territory, needs demand telemetry +
|
||||||
|
price-communication infrastructure — not single-lot booth territory.
|
||||||
|
|
||||||
|
**9. Fees/penalties** — lost ticket = worst-case exposure (max daily × assumed duration): ours
|
||||||
|
(`lostTicketMinor`). Post-payment exit grace, then overstay repricing: ours.
|
||||||
|
|
||||||
|
## Coverage map — our engine vs the industry
|
||||||
|
|
||||||
|
| Industry structure | Status in our engine |
|
||||||
|
| --- | --- |
|
||||||
|
| Linear hourly, per-started-increment | ✅ V1/V2 blocks + incrementMin |
|
||||||
|
| Degressive / progressive ladder | ✅ blocks |
|
||||||
|
| Daily cap, entry grace, exit grace | ✅ |
|
||||||
|
| Up-to matrix / day ticket | ✅ steps (defaultCard) |
|
||||||
|
| Day/night, weekend/holiday, seasonal | ✅ V2 windowed cards |
|
||||||
|
| Night/evening whole-window package | ✅ packageMinor (2026-07-05) |
|
||||||
|
| Event rate | ✅ expressible (date-ranged card) |
|
||||||
|
| Vehicle/customer category | ✅ card.category (capture seam pending) |
|
||||||
|
| Monthly/weekly contracts | ✅ subscription plans (separate) |
|
||||||
|
| Lost ticket, overstay reprice | ✅ |
|
||||||
|
| **Early bird / entry-time-conditioned** | ❌ gap — needs pick-table-by-entry-time (see [[tariff]] up-to/tiers incompatibility discussion) |
|
||||||
|
| **Validations / merchant discounts** | ❌ gap — no discount overlay mechanism; would need signed discount events + sponsor accounting |
|
||||||
|
| Pre-book/online channel rates | ❌ out of scope (no online channel; offline-first) |
|
||||||
|
| Dynamic/demand pricing | ❌ deliberately out — municipal/airport scale, needs telemetry; conflicts with printed-price predictability at a booth lot |
|
||||||
|
|
||||||
|
## Takeaways for the roadmap
|
||||||
|
|
||||||
|
1. **Coverage is already strong**: everything a single staffed lot typically advertises is
|
||||||
|
expressible today. The 2026-07-05 package mode closed the last everyday gap (real night
|
||||||
|
price).
|
||||||
|
2. **Early bird is the highest-value missing structure** and shares its mechanism with the
|
||||||
|
weekend-menu ask: select the ENTIRE rate table by entry instant (window → table), instead of
|
||||||
|
pricing per wall-clock slice. One future mechanism, two market features. Include min-stay /
|
||||||
|
exit-window conditions if built (anti-arbitrage is part of the product, not a nicety).
|
||||||
|
3. **Validations are the second candidate** once merchants near the site matter: time-credit +
|
||||||
|
percent + full-comp shapes, as signed appended events (fits the ledger model); sponsor
|
||||||
|
settlement stays outside the app (like drawer-review denials).
|
||||||
|
4. **Do not build**: per-minute billing (industry tried it, customers rejected it), dynamic
|
||||||
|
pricing (wrong scale + breaks price predictability at a booth).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- FHWA, *Contemporary Approaches to Parking Pricing: A Primer* — <https://ops.fhwa.dot.gov/publications/fhwahop12026/sec_2.htm>
|
||||||
|
- Arivo, *The Right Parking Tariff* (practitioner taxonomy) — <https://arivo.co/en/blog/optimal-parking-pricing>
|
||||||
|
- Pitane, *Per-minute parking creates chaos* (Leuven rollback) — <https://pitane.blue/en/2025/01/16/Parking-rates-in-garages-per-minute-parking-causes-chaos-according-to-experts/>
|
||||||
|
- Secure Parking, *Early Bird Parking* (entry/exit conditions) — <https://www.secureparking.com.au/en-au/parking-solutions/early-bird-parking/>
|
||||||
|
- ParkWhiz help, *What is Early Bird* — <https://help.parkwhiz.com/support/solutions/articles/60001010507-what-is-early-bird->
|
||||||
|
- Parking BOXX, *Validation programs guide* — <https://blog.parkingboxx.com/industry/parking-validation-programs-guide/>
|
||||||
|
- Amano McGann, *Validation solutions* (discount shapes incl. re-rate) — <https://amanomcgann.com/our-solutions-parking-management/validation/>
|
||||||
|
- ITS DOT evaluation of SFpark — <https://www.itskrs.its.dot.gov/2024-b01818>
|
||||||
|
- UCLA ITS, *Pricing Parking by Demand (SFpark)* — <https://www.its.ucla.edu/publication/pricing-parking-by-demand-sfpark/>
|
||||||
|
- IDeaS, *Airport parking dynamic pricing* — <https://ideas.com/airport-parking-dynamic-pricing/>
|
||||||
|
- SKIDATA, mobility & parking solutions (tariff axes) — <https://www.skidata.com/en-us/solutions/mobility-parking>
|
||||||
|
- City of Tampa / MPLS Parking / RDU T&Cs (published rate-card examples) —
|
||||||
|
<https://www.tampa.gov/parking/info/parking-hourly-and-daily-rates>,
|
||||||
|
<https://www.mplsparking.com/parking-rates>, <https://www.rdu.com/terms-conditions/>
|
||||||
@@ -193,7 +193,11 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||||
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||||
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||||
"Publish" creates a new version (past sessions keep their pricing).
|
"Publish" creates a new version (past sessions keep their pricing). Since 2026-07-05 a **right
|
||||||
|
sidebar lists the published history** (name or effective date, active badge — mirrors the lab's
|
||||||
|
sidebar); clicking a version **loads it into the editor as the starting point** for the next
|
||||||
|
publish (currency select ALL/EUR/USD; optional version-name field). Publishing never edits the
|
||||||
|
clicked version — the sidebar hint says so explicitly.
|
||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- 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).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||||
|
- [[tariff-industry-survey]] — 2026-07 web survey of industry tariff structures: coverage map vs our engine; gaps = early bird (pick-table-by-entry-time) + merchant validations; per-minute & dynamic pricing ruled out.
|
||||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||||
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
||||||
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
||||||
|
|||||||
+30
@@ -2342,3 +2342,33 @@ read as a dead click on a slow open. Root cause of the slowness recorded as an o
|
|||||||
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
|
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
|
||||||
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
|
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
|
||||||
migration means it was skipped; booth containers migrate on boot and are immune.
|
migration means it was skipped; booth containers migrate on boot and are immune.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Camera health-check detail bucketed — device-monitor log noise fix
|
||||||
|
|
||||||
|
The monitor logs + re-emits a device status only when state OR detail changes, but the camera
|
||||||
|
probe's detail was the exact snapshot byte count — which differs on every JPEG frame, so healthy
|
||||||
|
cameras "changed" on nearly every poll (a log line + websocket event each, inflating the freshly
|
||||||
|
budgeted container logs). Camera healthCheck detail is now a stable power-of-two bucket
|
||||||
|
("snapshot ≈16 KB" / "≈256 KB"; "<1 KB" stays exact as a suspicious-frame flag), so it moves only
|
||||||
|
on a real shift (stream/resolution change, empty body). packages/devices camera.ts + 3 tests.
|
||||||
|
|
||||||
|
## [2026-07-05] query | Industry survey: most-used parking tariff systems
|
||||||
|
|
||||||
|
Web research filed as [[tariff-industry-survey]] (reference). Field taxonomy: linear-hourly per
|
||||||
|
started increment (per-MINUTE tried and rolled back — Leuven), degressive ladders, day caps,
|
||||||
|
grace; whole-stay prices (day tickets, up-to matrices, evening/overnight packages, event rates);
|
||||||
|
entry-time-conditioned EARLY BIRD (enter-before cutoff, anti-arbitrage exit conditions); calendar
|
||||||
|
windows (day/night, weekend/holiday, seasonal); category pricing; monthly contracts; merchant
|
||||||
|
VALIDATIONS (amount/percent/time-credit/full-comp/re-rate); dynamic demand pricing (SFpark 60-80%
|
||||||
|
occupancy banding — municipal/airport scale only). Coverage map: our engine expresses everything
|
||||||
|
a staffed single lot advertises; the two real gaps are early bird (= the pick-table-by-entry-time
|
||||||
|
future design, same mechanism as weekend menus) and validation overlays. Anti-features confirmed:
|
||||||
|
per-minute billing, dynamic pricing.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Composer page gets the published-versions sidebar too
|
||||||
|
|
||||||
|
The published-history sidebar built for the lab landed only there; the operator expected it on
|
||||||
|
/setup/tariff as well. TariffComposer now mirrors it: right sidebar of published versions (name or
|
||||||
|
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
|
||||||
|
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
|
||||||
|
[[tariff]] (Composer section).
|
||||||
|
|||||||
Reference in New Issue
Block a user