9 Commits

Author SHA1 Message Date
julian f9887c2a76 fix(server): seed-admin self-heals the admin role + signs a ledger event
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).

It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.

Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 7649b897c4 feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian ab968eb25e feat(web): composer states the billing unit — the 60→10 price trap closed
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:

- Price headers name the real unit live: "Çmimi / orë" at 60,
  "Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
  charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
  the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
  up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
  as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
  new tier rows, and mode-switch templates alike.

Band DURATIONS stay in hours — real wall time, increment-independent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 5e1a885dcb feat(web): one date standard across the UI — "25 Qer 14:30"
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:

- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
  formatDate ("25 Qer", year only when not current), formatDateTime
  ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
  lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
  older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
  effective dates, sub version labels, drawer today feed, snapshot
  tooltips, device footer checkedAt, event-detail timestamp (keeps
  seconds — chain evidence), tariff composer active-since + version
  sidebar. Number toLocaleString (thousand separators) untouched.

The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 6cf3492bff chore(shift): Z-report slip label wording (Albanian)
Operator-adjusted labels on the printed Z-report: "Gjëndje fillestare"
for the opening float, aligned "Abonime"/"Jashtë orarit" rows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian ffe8c13a1c fix(web): setup wizard no longer forces printers to bind to a barrier
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.

Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian fcea992e1e refactor(devices): rename driver "cashino" → "escpos" (generic ESC/POS)
The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:

- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
  id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
  ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
  pre-rename backups still resolve instead of "unknown driver".

Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 81bc2e357c fix(devices): USB printing dropped the job tail — chunked write loop
Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00
38 changed files with 1268 additions and 159 deletions
+35 -1
View File
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db");
const { createDb, users, roles, eq } = require("@parking/db");
const DEFAULT_USERNAME = "admin";
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
}
const db = createDb();
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
// the row alone is all the FK needs.
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
const existing = await db.select().from(users).where(eq(users.username, username)).get();
if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
@@ -73,4 +81,30 @@ if (existing) {
});
console.log(`created admin "${username}"`);
}
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
// fact (the chain is the audit record; whoever holds root can reset a password, they
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
// admin out to protect an audit line would invert the priority.
try {
const { EventLog } = await import("../dist/event-log.js");
const { buildSigner } = await import("../dist/signer.js");
const log = new EventLog(db, buildSigner());
await log.append({
type: "config_change",
source: "manual",
identity: `user:${username}`,
payload: {
setting: existing ? "admin.passwordReset" : "admin.seeded",
username,
operator: "console:seed-admin",
},
});
console.log("recorded to the signed ledger (config_change)");
} catch (err) {
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
}
process.exit(0);
+65
View File
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
expect(r.subscriptions.coveredCars).toBe(2);
});
});
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
await entry(at("2026-05-20T08:00:00Z"));
await entry(at("2026-05-20T09:00:00Z"));
await entry(at("2026-05-21T10:00:00Z"));
await exit(at("2026-05-21T12:00:00Z"));
// In range: +2 on the 10th, −1 on the 11th.
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-11T09:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(2);
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
["2026-06-10", 4],
["2026-06-11", 3],
]);
});
it("a voided pre-range entry does not inflate occupancyStart", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(0);
});
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
await entry(at("2026-06-10T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
});
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
expect(counts["30"]).toBe(1);
expect(counts["120"]).toBe(1);
expect(counts["null"]).toBe(1);
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
expect(day.cashMinor).toBe(500);
expect(day.cardMinor).toBe(700);
});
it("counts voids and anomalies in range (the look-closer counters)", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.voids).toBe(1);
expect(r.totals.anomalies).toBe(1);
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
});
});
+105 -14
View File
@@ -4,9 +4,11 @@ import {
desc,
eq,
gte,
lt,
lte,
ledgerEvents,
sessions,
siteConfig,
subscriptions,
tariffVersions,
tariffs,
@@ -46,8 +48,13 @@ export interface SeriesPoint {
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Tender split of the bucket's revenue (cash = everything not card). */
readonly cashMinor: number;
readonly cardMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
readonly occupancyEnd: number;
}
export interface ReportTotals {
@@ -67,6 +74,10 @@ export interface ReportTotals {
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
* (the operator at the booth is the threat model's primary adversary). */
readonly voids: number;
readonly anomalies: number;
}
export interface SubscriptionStats {
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
readonly coveredCars: number;
}
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
export interface StayBucket {
readonly uptoMin: number | null;
readonly count: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
@@ -89,25 +107,43 @@ export interface ReportSummary {
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
readonly entriesByDowHour: number[][];
/** Stay-duration histogram over closed sessions in range. */
readonly stayHistogram: StayBucket[];
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
readonly occupancyStart: number;
/** Nominal capacity from site config (null = uncapped) — the reference line. */
readonly capacity: number | null;
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const fmtCache = new Map<string, Intl.DateTimeFormat>();
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
// Cached per tz — this runs once per ledger row in a report.
let fmt = fmtCache.get(tz);
if (!fmt) {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
weekday: "short",
});
fmtCache.set(tz, fmt);
}
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
};
}
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
const totals = {
entries: 0,
exits: 0,
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
voids: 0,
anomalies: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
seriesMap.set(label, p);
}
return p;
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
const lp = localParts(row.occurredAt, tz);
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "void") {
totals.voids++;
} else if (row.type === "anomaly") {
totals.anomalies++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
if (pl.tender === "card") {
totals.cardMinor += amt;
p.cardMinor += amt;
} else {
totals.cashMinor += amt;
p.cashMinor += amt;
}
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
// series. Voided pre-range entries cancel out the same way the in-range pass does.
// Sparse buckets (no events) simply carry the previous level — the step line is exact
// at every plotted point.
const prior = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.where(lt(ledgerEvents.occurredAt, q.from))
.all();
const priorVoided = new Set<string>();
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
let occupancyStart = 0;
for (const r of prior) {
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
else if (r.type === "vehicle_exit") occupancyStart--;
}
occupancyStart = Math.max(0, occupancyStart);
let running = occupancyStart;
for (const p of series) {
running = Math.max(0, running + p.entries - p.exits);
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
}
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
// the 8h working day, the 24h rolling day) so the chart answers "where should the
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
const stayHistogram: { uptoMin: number | null; count: number }[] = [
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
{ uptoMin: null, count: 0 },
];
for (const mins of durations) {
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
}
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
},
series,
entriesByHour,
entriesByDowHour,
stayHistogram,
occupancyStart,
capacity,
subscriptions: subStats,
};
}
+11 -2
View File
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
[
p.bucket,
p.entries,
p.exits,
p.payments,
(p.revenueMinor / 100).toFixed(2),
(p.cashMinor / 100).toFixed(2),
(p.cardMinor / 100).toFixed(2),
p.occupancyEnd,
].join(","),
),
];
reply
+8 -1
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
explainFee,
isTariffV2,
priceSession,
validateTariffStructure,
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
// HOW the amount is produced — the same engine walk with a trace collector
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
const breakdown = pricing.withinGrace
? null
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
const enteredMs = Date.parse(b.enteredAt);
@@ -196,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
+7 -7
View File
@@ -690,15 +690,15 @@ export class ShiftService {
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"",
"-- Arka --",
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "RAPORT TURNI", lines });
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type MovementStatus,
type ShiftSummary,
} from "./api.js";
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
@@ -223,7 +223,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
const amt = pl.amountMinor ?? 0;
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const time = formatClock(e.occurredAt);
const label =
e.type === "payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
+95 -12
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
@@ -12,6 +14,7 @@ import {
LineChart,
Pie,
PieChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -37,6 +40,7 @@ const C = {
border: "#2a2f38",
text: "#f2f2ee",
panel: "#14171c",
panel2: "#1e222a",
};
type PresetKey = "today" | "7d" | "30d" | "90d";
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
...p,
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
revenue: p.revenueMinor / 100,
cash: p.cashMinor / 100,
card: p.cardMinor / 100,
}));
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
const mix = [
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
].filter((s) => s.value > 0);
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
const stay = data.stayHistogram.map((b) => ({
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
count: b.count,
}));
return (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi
label={t("reports.kpi.subscribers")}
value={String(data.subscriptions.currentlyValid)}
label={t("reports.kpi.peakOcc")}
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
/>
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
</div>
{/* Entry / exit over time. */}
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</ResponsiveContainer>
</Panel>
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
(occupancy only moves at entries/exits); the red line is the configured cap. */}
<Panel title={t("reports.chart.occupancy")}>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
{data.capacity != null && (
<ReferenceLine
y={data.capacity}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
/>
)}
<Area
type="stepAfter"
dataKey="occupancyEnd"
name={t("reports.chart.occupancySeries")}
stroke={C.cyan}
fill={C.cyan}
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */}
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
)}
</Panel>
{/* Peak hours (entries by hour-of-day). */}
<Panel title={t("reports.chart.peakHours")}>
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
<Panel title={t("reports.chart.stay")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel>
</div>
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
the direct input for tariff windows (night rates, weekend cards, early bird). */}
<Panel title={t("reports.chart.heatmap")}>
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
</Panel>
<p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
);
}
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
const max = Math.max(1, ...matrix.flat());
return (
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="pb-0.5 text-center text-term-muted">
{h % 3 === 0 ? h : ""}
</span>
))}
{matrix.map((row, d) => (
<Fragment key={d}>
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
className="h-4 rounded-[1px]"
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
/>
))}
</Fragment>
))}
</div>
</div>
);
}
const tooltipStyle = {
background: C.panel,
border: `1px solid ${C.border}`,
+21 -4
View File
@@ -463,6 +463,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
</span>
);
}
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
// role instead of a bogus "unbound" warning.
if (assignment.category === "printer") {
const role = typeof cfg.role === "string" ? cfg.role : null;
return role ? (
<span className="text-term-muted">
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
</span>
) : null;
}
// Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
@@ -686,7 +696,11 @@ function DeviceForm({
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
}));
} else if (controllerId && boundRelay !== "") {
} else if (!isPrinter && controllerId && boundRelay !== "") {
// Readers/cameras bind to a controller relay (which barrier a scan opens +
// inherited direction). Printers do NOT — routing is role+failoverRank only,
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
// off on the next edit).
out.controllerId = controllerId;
out.relay = boundRelay;
}
@@ -759,7 +773,9 @@ function DeviceForm({
if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
// model with a fallback, but the wizard guides the admin to bind explicitly).
if (!isController && (!controllerId || boundRelay === "")) {
// Printers are exempt: nothing consumes a printer's binding — their routing is
// role + failoverRank (see printer-routing.ts).
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
setSaveError("Pick the controller and relay this device sits at.");
return;
}
@@ -959,8 +975,9 @@ function DeviceForm({
/>
)}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
{/* BOUND device: which controller + relay it sits at. Not printers —
nothing consumes a printer binding (role+rank routes print jobs). */}
{!isController && !isPrinter && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
type ShiftSummary,
type SessionUser,
} from "./api.js";
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
@@ -457,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
) : (
<div className="text-[0.8125rem] tabular-nums">
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
+3 -2
View File
@@ -27,6 +27,7 @@ import {
} from "./api.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { formatDateTime, type TFn } from "./lib/format.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
@@ -179,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
/** A one-line label for a plan VERSION in the correction picker: effective date + its
* timeframe summary (or "24/7" when the version has no window). */
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
function versionLabel(v: SubscriptionPlan, t: TFn): string {
const eff = new Date(v.effectiveFrom);
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
const tf = v.timeframes;
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
return `${date} · ${rules}`;
+2 -1
View File
@@ -14,6 +14,7 @@ import {
type SubscriptionPlan,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
import { formatDate } from "./lib/format.js";
import { currencyOptions } from "./lib/currencies.js";
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
@@ -277,7 +278,7 @@ export function SubscriptionPlansManager() {
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span>
<span>{timeframesSummary(p.timeframes, t)}</span>
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
</div>
{/* Used by */}
+4 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { formatDateTime } from "./lib/format.js";
// 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
@@ -77,7 +78,7 @@ export function TariffComposer() {
<p className="mb-4 text-[0.75rem] text-term-muted">
{state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
date: formatDateTime(state.active.effectiveFrom, t),
count: state.versions.length,
})}
</p>
@@ -126,7 +127,7 @@ export function TariffComposer() {
}`}
>
<span className="flex items-center gap-2 font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
{v.name ?? formatDateTime(v.effectiveFrom, t)}
{isActive && (
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
{t("tariff.activeBadge")}
@@ -134,7 +135,7 @@ export function TariffComposer() {
)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
+84 -32
View File
@@ -70,23 +70,35 @@ export interface FormState {
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptySteps(): StepForm[] {
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
* ~100× the euro ones; USD rides with EUR. */
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
return currency.trim().toUpperCase() === "ALL"
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
}
function emptySteps(currency: string): StepForm[] {
const ex = examples(currency);
return [
{ hours: "1", total: "2.00" },
{ hours: "3", total: "5.00" },
{ hours: "1", total: ex.stepSmall },
{ hours: "3", total: ex.stepBig },
];
}
function emptyLadder(): PricingForm {
function emptyLadder(currency: string): PricingForm {
const ex = examples(currency);
return {
mode: "ladder",
flat: "0.00",
packageTotal: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
steps: emptySteps(),
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
steps: emptySteps(currency),
};
}
function emptyTier(): TierForm {
function emptyTier(currency: string): TierForm {
return {
name: "",
priority: "10",
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
};
}
export function emptyForm(): FormState {
const currency = "ALL"; // the site's currency — examples scale with it
return {
currency: "ALL",
currency,
gracePeriodEntryMin: "15",
incrementMin: "60",
lostTicket: "20.00",
lostTicket: examples(currency).lost,
gracePeriodExitMin: "15",
base: emptyLadder(),
base: emptyLadder(currency),
tiers: [],
};
}
@@ -132,31 +145,34 @@ function stepsToForm(steps: TariffStep[]): StepForm[] {
// 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 {
function pricingFromCard(
c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
packageMinor?: number;
dailyCapMinor?: number | null;
},
currency: string,
): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
}
if (c.packageMinor != null) {
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
}
if (c.flatMinor != null) {
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
}
return {
...emptyLadder(),
...emptyLadder(currency),
mode: "ladder",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
};
}
function tierFromCard(c: TariffCard): TierForm {
function tierFromCard(c: TariffCard, currency: string): TierForm {
const w = c.window ?? {};
return {
name: c.name,
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
toHour: w.toHour ?? "",
dateFrom: w.dateFrom ?? "",
dateTo: w.dateTo ?? "",
pricing: pricingFromCard(c),
pricing: pricingFromCard(c, currency),
};
}
@@ -182,10 +198,14 @@ export function formFromVersion(currency: string, st: TariffStructure): FormStat
gracePeriodExitMin: String(st.gracePeriodExitMin),
};
if (isTariffV2(st)) {
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
return {
...common,
base: pricingFromCard(st.defaultCard, currency),
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
};
}
// V1: the bare ladder becomes the default card body; no tiers.
return { ...common, base: pricingFromCard(st), tiers: [] };
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
}
export function formFromActive(s: TariffState): FormState {
@@ -278,6 +298,8 @@ export function TariffEditorForm({
onChange: (update: (f: FormState) => FormState) => void;
}) {
const { t } = useTranslation();
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
onChange((f) => ({ ...f, [key]: value }));
@@ -322,7 +344,7 @@ export function TariffEditorForm({
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
}
function addTier() {
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
}
function removeTier(i: number) {
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
@@ -355,6 +377,15 @@ export function TariffEditorForm({
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
form reads naturally as per-hour; any other value silently redefines every
price below, so shout it (the 60→10 "six charges per hour" trap). */}
{inc !== 60 && (
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.incrementWarning", { min: inc })}
</p>
)}
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
wants tiers just edits this and publishes a bare V1 structure. */}
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
@@ -363,6 +394,7 @@ export function TariffEditorForm({
<PricingEditor
t={t}
pricing={form.base}
incrementMin={inc}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
@@ -434,6 +466,7 @@ export function TariffEditorForm({
<PricingEditor
t={t}
pricing={tr.pricing}
incrementMin={inc}
allowPackage
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
@@ -459,8 +492,11 @@ export function TariffEditorForm({
// (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;
t: (k: string, opts?: Record<string, unknown>) => string;
pricing: PricingForm;
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
* so the price labels state it explicitly instead of a vague "per increment". */
incrementMin: number;
allowStepped?: boolean;
allowPackage?: boolean;
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
@@ -475,6 +511,18 @@ function PricingEditor(props: {
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
* isn't an hour — at 60 the price already IS the hourly price). */
const perHour = (major: string): string | null => {
if (props.incrementMin === 60) return null;
const v = Number(major);
if (!Number.isFinite(v) || v <= 0) return null;
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
};
const unitLabel =
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
const flatLabel =
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
return (
<div>
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
@@ -484,7 +532,7 @@ function PricingEditor(props: {
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
{flatLabel}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
@@ -550,8 +598,9 @@ function PricingEditor(props: {
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<span className="label">{unitLabel}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
</div>
) : (
<>
@@ -559,7 +608,7 @@ function PricingEditor(props: {
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
<th />
</tr>
</thead>
@@ -579,7 +628,10 @@ function PricingEditor(props: {
)}
</td>
<td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
<span className="inline-flex items-center gap-2">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
</span>
</td>
<td className="px-2">
{!isTail && (
+94 -8
View File
@@ -15,7 +15,9 @@ import {
} 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";
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
import type { FeeBreakdown } from "@parking/shared";
import type { TFunction } from "i18next";
// 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
@@ -202,7 +204,7 @@ export function TariffLab() {
{selectedDraft
? selectedDraft.name
: selectedVersion
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
: t("lab.activeTariff")}
</span>
{selectedDraft && (
@@ -264,14 +266,19 @@ export function TariffLab() {
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
</>
)}
</dl>
{/* HOW the sum is produced — line items from the SAME engine walk
(their sum is the amount by construction). */}
{result.breakdown && (
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
)}
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
@@ -316,7 +323,7 @@ export function TariffLab() {
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
{d.currency} · {formatDateTime(d.updatedAt, t)}
</span>
</button>
</li>
@@ -345,7 +352,7 @@ export function TariffLab() {
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
</span>
</button>
</li>
@@ -363,10 +370,10 @@ export function TariffLab() {
}`}
>
<span className="block font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
{v.name ?? formatDateTime(v.effectiveFrom, t)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
/** The fee's line items — every row states its time window / rule and its amount, so
* the operator can retrace the exact sum (caps show as negative adjustments). */
function BreakdownTable({
b,
periodStart,
currency,
t,
}: {
b: FeeBreakdown;
periodStart: string;
currency: string;
t: TFunction;
}) {
const startMs = Date.parse(periodStart);
const multiDay = b.billedMinutes > 1440;
const at = (min: number) => {
const iso = new Date(startMs + min * 60_000).toISOString();
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
};
const money = (m: number) => formatMoney(m, currency);
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
return (
<div className="mt-3 border-t border-term-border pt-2">
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
{b.billedMinutes > 0 && (
<p className="hint mb-1.5">
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
</p>
)}
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{b.items.map((it, i) => {
let label: string;
let amount: number;
let cls = "text-term-text";
switch (it.kind) {
case "grace":
label = t("lab.bd.grace", { min: it.minutes });
amount = 0;
cls = "text-term-green";
break;
case "band":
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
amount = it.amountMinor;
break;
case "package":
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
amount = it.amountMinor;
break;
case "step":
label = it.repeated
? t("lab.bd.stepRepeated", { day: it.day })
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
amount = it.amountMinor;
break;
case "cap":
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
amount = it.amountMinor;
cls = "text-term-red";
break;
}
return (
<tr key={i} className="border-b border-term-border/40">
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
</tr>
);
})}
<tr>
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
</tr>
</tbody>
</table>
</div>
);
}
+15
View File
@@ -483,7 +483,11 @@ export interface ReportSeriesPoint {
entries: number;
exits: number;
revenueMinor: number;
cashMinor: number;
cardMinor: number;
payments: number;
/** Cars inside at the END of the bucket. */
occupancyEnd: number;
}
export interface ReportTotals {
@@ -500,6 +504,8 @@ export interface ReportTotals {
totalParkedMinutes: number;
avgParkedMinutes: number;
medianParkedMinutes: number;
voids: number;
anomalies: number;
}
export interface ReportSubscriptionStats {
@@ -519,6 +525,12 @@ export interface ReportSummary {
totals: ReportTotals;
series: ReportSeriesPoint[];
entriesByHour: number[];
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
entriesByDowHour: number[][];
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
stayHistogram: { uptoMin: number | null; count: number }[];
occupancyStart: number;
capacity: number | null;
subscriptions: ReportSubscriptionStats;
}
@@ -750,6 +762,9 @@ export interface SimSessionPricing {
export interface SimulateResult {
currency: string | null;
pricing: SimSessionPricing;
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
* null when the session is settled (within walk-back grace). */
breakdown: import("@parking/shared").FeeBreakdown | null;
curve: { minutes: number; amountMinor: number }[];
gracePeriodExitMin: number;
}
+39 -5
View File
@@ -85,6 +85,43 @@ function monthName(d: Date, t: TFn): string {
return String(d.getMonth() + 1);
}
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
function monthShort(d: Date, t: TFn): string {
const months = t("common.monthsShort", { returnObjects: true });
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
return months[d.getMonth()] as string;
}
return monthName(d, t);
}
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
export function formatClock(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
}
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
export function formatDate(iso: string | null, t: TFn): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const base = `${d.getDate()} ${monthShort(d, t)}`;
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
}
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : "";
return `${formatDate(iso, t)} ${hhmm(d)}${sec}`;
}
/**
* Human, day-relative date+time for sessions/logs/history. An event from earlier
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
@@ -101,9 +138,6 @@ export function formatRelativeDateTime(iso: string | null, t: TFn): string {
const diff = dayDiff(d, new Date());
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
const sameYear = d.getFullYear() === new Date().getFullYear();
const month = monthName(d, t);
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
return `${date} ${hhmm(d)}`;
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
return `${formatDate(iso, t)} ${hhmm(d)}`;
}
+27 -1
View File
@@ -33,6 +33,7 @@ export const en: Catalog = {
"November",
"December",
],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
},
auth: {
title: "Parking System",
@@ -330,6 +331,12 @@ export const en: Catalog = {
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment (per hour)",
pricePerHour: "Price / hour",
pricePerN: "Price / {{min}} min",
modeFlatN: "Flat price / {{min}} min",
perHourEquiv: "= {{amount}} / hour",
incrementWarning:
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
@@ -562,6 +569,16 @@ export const en: Catalog = {
graceExpires: "Grace expires",
curve: "Duration curve",
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
bd: {
title: "How the amount is produced",
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
grace: "Free — within the entry grace ({{min}} min)",
package: "window package",
step: "Day {{day}}: stay up to {{hours}}h — total",
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
cap: "Daily cap {{cap}} applied (day {{day}})",
total: "Total",
},
},
subs: {
title: "Subscriptions",
@@ -866,14 +883,23 @@ export const en: Catalog = {
payments: "Payments",
avgStay: "Avg stay",
subscribers: "Subscribers",
peakOcc: "Peak occupancy",
voids: "Voided tickets",
anomalies: "Anomalies",
},
chart: {
flow: "Entries & exits over time",
occupancy: "Occupancy — cars inside",
occupancySeries: "Cars inside",
revenue: "Revenue ({{currency}})",
mix: "Revenue mix",
peakHours: "Entries by hour of day",
stay: "Stay duration (closed sessions)",
heatmap: "Entries heatmap — hour × day",
breakdown: "Breakdown",
},
capacityLine: "capacity",
stay: { m: "m", h: "h" },
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: {
cash: "Cash",
+29 -2
View File
@@ -35,6 +35,8 @@ export const sq = {
"Nëntor",
"Dhjetor",
],
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
},
auth: {
title: "Sistemi i Parkimit",
@@ -332,7 +334,13 @@ export const sq = {
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval (orë)",
pricePerIncrement: "Çmimi / interval (min)",
pricePerHour: "Çmimi / orë",
pricePerN: "Çmimi / {{min}} min",
modeFlatN: "Çmim fiks / {{min}} min",
perHourEquiv: "= {{amount}} / orë",
incrementWarning:
"Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
@@ -574,6 +582,16 @@ export const sq = {
graceExpires: "Afati skadon",
curve: "Kurba sipas kohëzgjatjes",
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
bd: {
title: "Si prodhohet shuma",
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
grace: "Falas — brenda minutave të hirit ({{min}} min)",
package: "paketë dritareje",
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
total: "Totali",
},
},
subs: {
title: "Abonimet",
@@ -881,14 +899,23 @@ export const sq = {
payments: "Pagesa",
avgStay: "Qëndrim mes.",
subscribers: "Abonentë",
peakOcc: "Zënia maksimale",
voids: "Bileta të anuluara",
anomalies: "Anomali",
},
chart: {
flow: "Hyrjet & daljet me kalimin e kohës",
occupancy: "Zënia — makina brenda",
occupancySeries: "Makina brenda",
revenue: "Të ardhurat ({{currency}})",
mix: "Përbërja e të ardhurave",
peakHours: "Hyrjet sipas orës së ditës",
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
heatmap: "Harta e hyrjeve — orë × ditë",
breakdown: "Ndarja",
},
capacityLine: "kapaciteti",
stay: { m: "m", h: "o" },
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
row: {
cash: "Para në dorë",
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
import { formatClock } from "../lib/format.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
@@ -184,7 +185,7 @@ export function DeviceFooter() {
</div>
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
</div>
</div>
</li>
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDateTime } from "../lib/format.js";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
key={`${p.plate}-${p.direction}-${i}`}
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
}`}
>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
type="button"
onClick={() => setZoom(s.id)}
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
>
<img
src={snapshotImageUrl(s.id)}
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
<div
key={`fail-${f.direction ?? "both"}-${i}`}
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
>
<span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { type ReactNode } from "react";
import { type LedgerEvent } from "../api.js";
import { formatMoney } from "../lib/format.js";
import { formatMoney, formatDateTime } from "../lib/format.js";
import { renderReason } from "../lib/reason.js";
import { Modal } from "./Modal.js";
import { SnapshotStrip } from "./SnapshotStrip.js";
@@ -226,7 +226,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
<div>
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
@@ -0,0 +1,6 @@
-- Driver rename (2026-07-06): "cashino" → "escpos". The driver was always the GENERIC
-- ESC/POS printer driver (reachability-only clones); it carried the first unit's vendor
-- name, which read as misleading in the setup UI once other clones (ICS/Xprinter
-- XP-K200L) used it. Rewrite stored device rows; the registry also keeps a permanent
-- cashino→escpos alias so restored pre-rename backups still resolve.
UPDATE `devices` SET `driver_id` = 'escpos' WHERE `driver_id` = 'cashino';
+7
View File
@@ -162,6 +162,13 @@
"when": 1781886500000,
"tag": "0022_tariff_version_name",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1781886600000,
"tag": "0023_driver_id_escpos",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+3 -3
View File
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { rongtaDriver } from "./printer-rongta.js";
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
registry.register(rongtaDriver);
registry.register(cashinoDriver);
registry.register(escposDriver);
}
export {
@@ -35,5 +35,5 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
};
@@ -10,6 +10,7 @@ import {
probeUsb,
sendRawUsb,
transportFromConfig,
writeAllUsb,
stamp,
} from "./printer-escpos.js";
@@ -183,3 +184,63 @@ describe("stamp (Albanian date format)", () => {
expect(stamp("not-a-date")).toBe("not-a-date");
});
});
describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => {
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write.
// The old single-write path dropped everything past the first buffer — the ICS
// XP-K200L printed the ticket's text head but lost the barcode and the cut. A
// regular file can't reproduce that, so these drive the loop with a fake handle.
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
function slowHandle(cap: number) {
const chunks: Buffer[] = [];
return {
chunks,
write(buffer: Buffer, offset: number, length: number) {
const n = Math.min(cap, length);
chunks.push(Buffer.from(buffer.subarray(offset, offset + n)));
return Promise.resolve({ bytesWritten: n });
},
};
}
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
const h = slowHandle(100); // way smaller than the job → many partial writes
await writeAllUsb(h, payload, Date.now() + 2000);
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
});
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
const payload = Buffer.from("x".repeat(300));
let calls = 0;
const accepted: Buffer[] = [];
const h = {
write(buffer: Buffer, offset: number, length: number) {
calls++;
if (calls % 2 === 0) {
const err = new Error("EAGAIN") as NodeJS.ErrnoException;
err.code = "EAGAIN";
return Promise.reject(err);
}
const n = Math.min(120, length);
accepted.push(Buffer.from(buffer.subarray(offset, offset + n)));
return Promise.resolve({ bytesWritten: n });
},
};
await writeAllUsb(h, payload, Date.now() + 2000);
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
});
it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => {
const h = { write: () => Promise.resolve({ bytesWritten: 0 }) };
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/);
});
it("a non-EAGAIN error surfaces immediately", async () => {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
const h = { write: () => Promise.reject(err) };
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO");
});
});
+55 -5
View File
@@ -600,10 +600,60 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
});
}
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
* so jobs are pushed in chunks safely under that. */
const USB_WRITE_CHUNK = 4096;
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
* regular file can't reproduce the char device's partial writes / EAGAIN). */
export interface UsbWriteHandle {
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
}
/**
* Push the WHOLE payload through a non-blocking usblp fd. On O_NONBLOCK the kernel
* takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN
* when full) — a single fire-and-forget write() silently drops the tail of any job
* bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over
* USB): the text head printed, but the barcode mid-payload and the CUT at the end
* were in the dropped tail — "prints, but no barcode and no cut", while the same
* bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN
* and zero-byte writes with a short pause, bounded by the caller's deadline.
*/
export async function writeAllUsb(
handle: UsbWriteHandle,
payload: Buffer,
deadlineMs: number,
): Promise<void> {
let off = 0;
while (off < payload.length) {
if (Date.now() > deadlineMs) {
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
}
try {
const { bytesWritten } = await handle.write(
payload,
off,
Math.min(USB_WRITE_CHUNK, payload.length - off),
);
off += bytesWritten;
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
await delay(10); // printer draining its buffer — retry until the deadline
} else {
throw err;
}
}
}
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device: a single open + write delivers the job — there is no
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
* truncate the stream). We always close the handle (even on a failed write). */
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
* but delivery must go through the chunked loop above (see its doc for why). We
* always close the handle (even on a failed write). */
export async function sendRawUsb(
devicePath: string,
payload: Buffer,
@@ -615,7 +665,7 @@ export async function sendRawUsb(
"usb open timeout",
);
try {
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
} finally {
await handle.close();
}
@@ -701,7 +751,7 @@ export const transportField: ConfigField = {
required: true,
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
{ value: "tcp-ip", label: "Network (raw TCP)" },
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// End-to-end transport routing through the real driver: a USB-configured generic
// ESC/POS printer (Cashino / ICS XP-K200L family) must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
describe("escposDriver (generic ESC/POS) — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
const absent = escposDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
expect(escposDriver.transports).toContain("usb");
expect(escposDriver.transports).toContain("tcp-ip");
});
});
@@ -23,25 +23,26 @@ import {
type Transport,
} from "./printer-escpos.js";
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
// clone: it PRINTS identically to the Rongta (same byte stream — see
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
// over either transport. What it does NOT have is the Rongta board's decoded status
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
// in a form we trust.
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
// XP-K200L. Tickets, reports and subscription cards render identically to the
// Rongta, over either transport; what these clones can NOT do is report paper-out /
// cover-open / cutter faults in a form we trust.
//
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
// driver resolves it ONCE into a Transport and every print/probe stays transport-
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone is the natural USB candidate — reachability-only, no status page to lose.
// clone family is the natural USB candidate — reachability-only, no page to lose.
//
// Therefore this driver deliberately does NOT implement MonitorableDevice
// (no readStatus). The device monitor then falls back to the generic
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
// booth footer shows this printer as "ready" when it's reachable and "offline"
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
// (Reusing the Rongta driver made it scrape a status page these clones don't
// serve, producing the bogus "degraded" feedback this driver fixes.)
//
// No auth on the print socket — like the other field devices it lives on the
@@ -49,8 +50,8 @@ import {
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
class CashinoPrinter implements PrinterDevice {
readonly driverId = "cashino";
class GenericEscposPrinter implements PrinterDevice {
readonly driverId = "escpos";
readonly #transport: Transport;
readonly #timeout: number;
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
/**
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* the print path. The Cashino has no trustworthy status protocol, so this is the
* the print path. These clones have no trustworthy status protocol, so this is the
* floor and the ceiling of what we report: reachable → ready, unreachable →
* offline. Deliberately NO readStatus(): the monitor uses this for the
* traffic-light, never a guessed paper/cover state.
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const cashinoDriver: PrinterDriver = {
id: "cashino",
export const escposDriver: PrinterDriver = {
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
// used it. Stored configs with driverId "cashino" still resolve via the registry
// alias + are rewritten by migration 0023.
id: "escpos",
category: "printer",
label: "Cashino 80mm thermal printer",
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: [
transportField,
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
default: 3000,
},
],
create: (c) => new CashinoPrinter(c),
create: (c) => new GenericEscposPrinter(c),
};
+1 -1
View File
@@ -18,7 +18,7 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
} from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
+10 -2
View File
@@ -97,6 +97,14 @@ export function isDiscoverable(
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
}
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
* tiny + permanent so old DB rows, exports, and backups resolve across renames
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
* the historical id). */
const DRIVER_ID_ALIASES: Record<string, string> = {
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
};
class DeviceRegistry {
readonly #drivers = new Map<string, DeviceDriver>();
@@ -114,12 +122,12 @@ class DeviceRegistry {
}
get(id: string): DeviceDriver | undefined {
return this.#drivers.get(id);
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
}
/** Validate config against a driver's declared fields and build the adapter. */
create(id: string, config: DeviceConfig): Device {
const driver = this.#drivers.get(id);
const driver = this.get(id);
if (!driver) throw new Error(`unknown driver: ${id}`);
for (const field of driver.configFields) {
if (field.required && config[field.key] === undefined) {
+170 -12
View File
@@ -764,6 +764,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
return Array.isArray(s.steps) && s.steps.length > 0;
}
// --- Fee breakdown (explainability) -------------------------------------------
// One line item per priced "reason": a run of same-priced increments, a window
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
// Produced by the SAME walk computeFee runs (an optional trace collector inside
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
// never tell a different story than the bill. Built for the Tariff Lab's "how is
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
// period's start.
export type FeeBreakdownItem =
/** The whole stay fit inside the free entry-grace window (fee 0). */
| { readonly kind: "grace"; readonly minutes: number }
/** A contiguous run of increments billed at one unit price by one card.
* `card` is the windowed card's name, or null for the base/default rate. */
| {
readonly kind: "band";
readonly card: string | null;
readonly fromMin: number;
readonly toMin: number;
readonly increments: number;
readonly unitMinor: number;
readonly amountMinor: number;
}
/** One window-package occurrence (charged once per contiguous run the card wins). */
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
| {
readonly kind: "step";
readonly day: number;
readonly dayMinutes: number;
readonly uptoMin: number;
readonly amountMinor: number;
readonly repeated: boolean;
}
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
export interface FeeBreakdown {
/** Actual stay length in whole minutes (before increment rounding). */
readonly rawMinutes: number;
/** Minutes billed after rounding UP to the increment (0 within grace). */
readonly billedMinutes: number;
readonly incrementMin: number;
readonly items: FeeBreakdownItem[];
/** Σ item amounts — always equals computeFee for the same arguments. */
readonly totalMinor: number;
}
/**
* Explain a fee: run the exact computeFee walk with a trace collector and return
* the line items plus the total. Same arguments as computeFee; the total returned
* here IS computeFee's answer (one code path, not a parallel calculation).
*/
export function explainFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
category?: string,
): FeeBreakdown {
const items: FeeBreakdownItem[] = [];
const totalMinor = isTariffV2(tariff)
? computeFeeV2(enteredAt, asOf, tariff, category, items)
: computeFeeV1(enteredAt, asOf, tariff, items);
const ms = Date.parse(asOf) - Date.parse(enteredAt);
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
const inc = Math.max(1, tariff.incrementMin);
const inGrace = items.length === 1 && items[0]!.kind === "grace";
const billedMinutes =
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
? 0
: Math.ceil(ms / 60_000 / inc) * inc;
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
}
/** Band-merging helper for the trace: accumulate consecutive increments that share
* a (card, unit price) and flush them as one `band` item. */
class BandTracer {
#card: string | null = null;
#unit = 0;
#from = 0;
#count = 0;
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
add(card: string | null, unitMinor: number, atMin: number): void {
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
this.#count++;
return;
}
this.flush();
this.#card = card;
this.#unit = unitMinor;
this.#from = atMin;
this.#count = 1;
}
flush(): void {
if (this.#count === 0) return;
this.items.push({
kind: "band",
card: this.#card,
fromMin: this.#from,
toMin: this.#from + this.#count * this.inc,
increments: this.#count,
unitMinor: this.#unit,
amountMinor: this.#count * this.#unit,
});
this.#count = 0;
}
}
/**
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
@@ -771,7 +880,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
*/
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
if (minutes <= 0 || steps.length === 0) return 0;
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
const top = sorted[sorted.length - 1]!;
@@ -780,8 +889,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
const found = sorted.find((s) => dayMin <= s.uptoMin);
const tier = found ?? top;
total += tier.totalMinor;
trace?.push({
kind: "step",
day: dayStart / DAY + 1,
dayMinutes: dayMin,
uptoMin: tier.uptoMin,
amountMinor: tier.totalMinor,
repeated: found == null,
});
}
return total;
}
@@ -791,30 +909,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
* identically. Do not "unify" this into the V2 path: a rounding divergence would
* corrupt repricing of already-signed sessions. A `steps` table (when present)
* REPLACES the ladder via {@link steppedFee}. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
function computeFeeV1(
enteredAt: string,
asOf: string,
tariff: TariffStructureV1,
trace?: FeeBreakdownItem[],
): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
// 60 min — otherwise rounding-up would defeat the grace window).
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
return 0;
}
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
for (let within = 0; segStart + within < segEnd; within += inc) {
segFee += rateAt(tariff.blocks, within);
const unit = rateAt(tariff.blocks, within);
segFee += unit;
bands?.add(null, unit, segStart + within);
}
bands?.flush();
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
trace?.push({
kind: "cap",
day: segStart / DAY + 1,
capMinor: tariff.dailyCapMinor,
amountMinor: tariff.dailyCapMinor - segFee,
});
segFee = tariff.dailyCapMinor;
}
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
total += segFee;
}
return total;
@@ -837,12 +975,16 @@ function computeFeeV2(
asOf: string,
tariff: TariffStructureV2,
category?: string,
trace?: FeeBreakdownItem[],
): number {
const enteredMs = Date.parse(enteredAt);
const ms = Date.parse(asOf) - enteredMs;
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
return 0; // grace on RAW duration (V1 rule)
}
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
@@ -862,7 +1004,11 @@ function computeFeeV2(
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
// windowed cards (they have nothing to override at the increment level). This is the
// only sound place for steps in V2. See wiki/concepts/tariff.md.
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
// its name.
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
let total = 0;
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
@@ -876,21 +1022,33 @@ function computeFeeV2(
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall);
if (card.packageMinor != null) {
// First increment of a new occurrence pays the package; the rest ride free.
if (prevWinner !== card) segFee += card.packageMinor;
if (prevWinner !== card) {
segFee += card.packageMinor;
bands?.flush();
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
}
} else if (card.flatMinor != null) {
segFee += card.flatMinor;
bands?.add(traceName(card), card.flatMinor, within);
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart);
const unit = rateAt(card.blocks ?? [], within - segStart);
segFee += unit;
bands?.add(traceName(card), unit, within);
}
prevWinner = card;
}
if (dayCap != null) segFee = Math.min(segFee, dayCap);
bands?.flush();
if (dayCap != null && segFee > dayCap) {
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
segFee = dayCap;
}
total += segFee;
}
return total;
+95
View File
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import {
computeFee,
explainFee,
priceSession,
validateTariffStructure,
type FeeBreakdownItem,
type TariffStructure,
type TariffStructureV1,
type TariffStructureV2,
type TariffCard,
@@ -437,3 +440,95 @@ describe("V2 window package (whole-window total)", () => {
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
});
});
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
const v1: TariffStructure = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [
{ uptoMin: 120, priceMinorPerIncrement: 200 },
{ uptoMin: null, priceMinorPerIncrement: 100 },
],
dailyCapMinor: 500,
};
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
const from = "2026-07-06T08:00:00.000Z";
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
const b = explainFee(from, to, v1);
expect(b.totalMinor).toBe(computeFee(from, to, v1));
expect(b.totalMinor).toBe(500);
expect(sum(b)).toBe(b.totalMinor);
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
const [first, second, cap] = b.items as [
Extract<FeeBreakdownItem, { kind: "band" }>,
Extract<FeeBreakdownItem, { kind: "band" }>,
Extract<FeeBreakdownItem, { kind: "cap" }>,
];
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
expect(cap.amountMinor).toBe(-500);
expect(b.billedMinutes).toBe(480);
expect(b.rawMinutes).toBe(422);
});
it("grace: one zero line, billed 0", () => {
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
expect(b.totalMinor).toBe(0);
expect(b.billedMinutes).toBe(0);
});
it("stepped: one line per rolling day, top tier repeats flagged", () => {
const stepped: TariffStructure = {
...v1,
blocks: [],
dailyCapMinor: null,
steps: [
{ uptoMin: 180, totalMinor: 500 },
{ uptoMin: 1440, totalMinor: 1000 },
],
};
const from = "2026-07-04T08:00:00.000Z";
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
const b = explainFee(from, to, stepped);
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
expect(sum(b)).toBe(b.totalMinor);
expect(b.items).toEqual([
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
]);
});
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
const v2: TariffStructure = {
version: 2,
tz: "Europe/Tirane",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
windowedCards: [
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
],
};
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
const from = "2026-07-06T16:00:00.000Z";
const to = "2026-07-06T20:30:00.000Z";
const b = explainFee(from, to, v2);
expect(b.totalMinor).toBe(computeFee(from, to, v2));
expect(sum(b)).toBe(b.totalMinor);
expect(b.totalMinor).toBe(2 * 10000 + 40000);
expect(b.items).toEqual([
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
]);
});
});
+24 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, device, printer, transport, usb, escpos, provisioning]
sources: []
updated: 2026-06-24
updated: 2026-07-06
status: settled
---
@@ -87,5 +87,28 @@ The setup UI offers a **Connection** select (Network / USB) + a **USB device** p
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
are pending (open-questions #14).
## Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06)
First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over
USB it printed the ticket's TEXT but **no barcode and no cut**. Root cause was in OUR transport,
not the printer: `sendRawUsb` opened the node with `O_NONBLOCK` and issued ONE `write()` for the
whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer
(~8 KB) and returns a **short write**; the old code never checked `bytesWritten`, closed the
handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the
CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test
stand-in can't short-write, so tests never caught it.
Fix: `writeAllUsb` — chunked loop (4 KB, safely under the usblp buffer) that continues after
partial writes, retries `EAGAIN`/zero-byte writes with a short pause, and fails at the caller's
deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (short writes,
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
reproduce the char device's behaviour.
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
+25 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, reporting]
sources: []
updated: 2026-06-22
updated: 2026-07-05
status: open
---
@@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
([[open-questions]] #4).
## As-built additions (2026-07-05) — the parking-shaped graphics
Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts,
ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are
parking-specific rather than generic BI, plus fraud counters:
- **Occupancy curve** — cars-inside step-area over the range with the configured capacity as a
red reference line. `occupancyStart` folds the ENTIRE prior ledger (voided entries excluded,
clamped ≥0); each series point carries `occupancyEnd`. The at-a-glance answer to "when are we
near full" — the input for capacity and dynamic-window decisions.
- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday,
site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and
morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards,
early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly
contains it).
- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail
(`stayHistogram`): where the ladder/up-to breakpoints should sit.
- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike
is exactly what the signed chain exists to surface (the operator is the threat model).
- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point —
the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained
cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one
`new Intl.DateTimeFormat` per ledger row).
## Open
- Which reports matter at launch vs. later; the export format/cadence.
+36 -3
View File
@@ -2,7 +2,7 @@
type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: []
updated: 2026-06-30
updated: 2026-07-06
status: settled
---
@@ -384,8 +384,41 @@ docker exec -it \
> setup only). It is a **training/demo** tool — never run on a production booth's data.
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the
documented re-seed died on a `role_id` FOREIGN KEY error (field failure on `park-buzi`). For dev
(where `pnpm` exists) the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
### 7e. Lost APP admin password — reset from the Linux admin account (2026-07-06)
The app's admin password lives only as a bcrypt hash in the booth DB; there is no in-app recovery
(nobody above the admin exists to send a reset). The recovery path is the **Linux `admin` account**
(the only user in the `docker` group): the seed script doubles as the password-reset tool via
`FORCE=1` — on an existing username it RESETS that user's password (and restores `roleId: admin`,
so it also rescues a demoted admin).
```bash
# Interactive (preferred — the password never lands in shell history):
docker exec -it -e FORCE=1 park-buzi-server-1 node scripts/seed-admin.mjs
# → prompts: username (Enter = admin), new password (min 8 chars)
# Non-interactive (scripted; NB the password enters the HOST's shell history):
docker exec -e FORCE=1 -e ADMIN_USER=admin -e ADMIN_PASS='new-strong-pass' \
park-buzi-server-1 node scripts/seed-admin.mjs
```
- **Attributable, not gated.** Whoever holds Linux root owns the DB file — the app cannot defend
against that actor and doesn't pretend to. What it CAN do: the script appends a **signed
`config_change` ledger event** (`admin.passwordReset` / `admin.seeded` on first seed, operator
`console:seed-admin`) so a console reset stays visible in the chain afterwards. If the signing key
is unavailable (e.g. a dev shell), it warns loudly and proceeds — locking an admin out to protect
an audit line would invert the priority. The [[threat-model]] adversary remains the *operator*,
who has no Linux account at all.
- **Sessions are NOT revoked** by a password reset — issued JWT cookies ride to expiry. A *forgotten*
password needs nothing more; a *suspected-stolen* one should also rotate the booth's `JWT_SECRET`
(Komodo Variables → redeploy), which invalidates every session instantly.
- Works on a fresh/reset DB too (the role-row self-heal above), so §7b first-seed, §7d post-reset
re-seed, and this recovery are all the same one command.
### Healthy startup + web-access
+87
View File
@@ -2372,3 +2372,90 @@ The published-history sidebar built for the lab landed only there; the operator
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).
## [2026-07-05] update | Reports dashboard: occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
Operator ask on /reports. Added the parking-shaped graphics (details on [[reporting-analytics]]):
occupancy step-area vs capacity line (prior-ledger fold for range-start, per-bucket occupancyEnd),
entries heatmap hour×day-of-week (replaces the flat hour histogram; feeds tariff-window design per
[[tariff-industry-survey]]), stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h),
voids+anomalies KPI counters (accented when >0 — the look-closer signal), cash/card stacked revenue
bars, peak-occupancy KPI, richer CSV (cash/card/occupancy_end). Server: reports.ts aggregation +
Intl formatter cached per tz; db package re-exports lt/gt. 5 new tests (295 server green).
## [2026-07-06] update | USB printer truncation fixed (NONBLOCK partial write) — ICS XP-K200L
First on-hardware USB print test failed exactly as the transport bug predicts: text printed,
barcode + cut missing (both live in the dropped tail). sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — anything past the printer's ~8 KB USB buffer was
silently discarded; TCP was immune. Fixed with writeAllUsb (4 KB chunks, partial-write
continuation, EAGAIN retry, deadline with N/M diagnostic) + 4 fake-handle tests. Also verified on
hardware (10.0.10.11): the ICS XP-K200L serves NO /prn_stat.htm → on network use the cashino
driver (reachability-only), not rongta, or monitoring calls a working printer offline. Details on
[[printer-usb-transport]].
## [2026-07-06] update | Driver rename: "cashino" → "escpos" (generic ESC/POS printer)
The ICS XP-K200L exposed that the reachability-only clone driver carried its first unit's vendor
name — "cashino" in the setup UI was misleading for every other clone. Renamed properly:
printer-cashino.ts → printer-generic.ts, id "cashino" → "escpos", label "Generic ESC/POS 80mm
printer (Cashino, ICS/Xprinter…)". Stored rows rewritten by migration 0023; the registry keeps a
PERMANENT cashino→escpos alias so restored pre-rename backups still resolve. Prose mentions of the
Cashino as hardware stay (it's a real printer). Driver guidance for the XP-K200L: escpos on both
transports (it serves no /prn_stat.htm — verified; rongta would false-flag it). See
[[printer-usb-transport]], [[printer-status-monitoring]].
## [2026-07-06] update | Setup wizard: printers no longer forced to bind to a barrier relay
Operator hit the wizard's blanket "pick the controller and relay this device sits at" gate while
adding the ICS printer. The binding (controllerId+relay → which barrier a scan opens + inherited
direction) is load-bearing for READERS and CAMERAS only; nothing consumes it on a printer —
printer routing is role + failoverRank (printer-routing.ts). Wizard now skips the requirement,
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
same-price increment runs (time window · N × unit · card name), window-package occurrences,
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
wiki's open "composer price preview" item — see [[tariff]].
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
plausible templates. Blank-form currency stays ALL.
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.