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
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Shift screen:
- The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now
appears at the TOP of the shift list (CURRENT badge, live figures synthesized from
the X-report), unified with history. Selecting it shows its live activity log.
- Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL:
End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out),
takings-so-far (X-report). When no shift is open, a Start-shift button shows.
- The current shift's log auto-refreshes (5s); a closed shift is bounded by its
window. /setup/shifts stays read-only history (no manage props). Deleted the now-
orphaned ShiftControl.tsx.
Layout:
- Every screen is now full-width like /booth — stripped the per-screen
`mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup
layout, Shifts). The shell <main> already provides padding.
Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT
list entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Addresses three issues with the plan catalog screen:
1. Retired plans had NO actions (the action cell was gated on "current
version", which a retired plan lacks) — so there was no way to make one
in-force again. Add POST /:planId/reactivate (inverse of retire) + a
Reactivate button on retired plans.
2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
reference the planId (any version) — a referenced plan version must survive
for reproducible repricing/audit, so an in-use delete returns 409 and the UI
says "retire it instead". The Delete button only shows when the plan has 0
subscribers.
3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
status badges wrapped to a second line. Replace it with a CARD list (one card
per planId, grouped across versions): name + status on top, price · hours ·
effective on a wrap row, "used by N" expandable to holder names, and actions
on their own bordered row — nothing overlaps, badges stay inline.
Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:
- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
total), expandable to the holder names — so you can see what depends on a plan
before retiring or replacing it. Computed client-side from the existing
subscriptions list (both screens are admin-grade; no new endpoint).
Build+lint 12/12 (i18n parity).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).
Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.
outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.
- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
keyed by a stable planId; period day/week/month + per-period price + active
flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
"Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
amount = periods × per-period price. Ceil = any started period is full (hotel
practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
sell path derives the amount from the plan; `POST /api/subscriptions/quote`
returns a server-computed quote so the operator can't override it. The
signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
picker + start/end dates + a live quote line. New SubscriptionPlansManager
(Setup tab) for the admin catalog. i18n (sq+en) for both.
Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V