);
}
+// --- Record form (unchanged from the pre-redesign feature) ------------------
+
function RecordPanel({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx
index e66a4a4..875eb37 100644
--- a/apps/web/src/ShiftsHistory.tsx
+++ b/apps/web/src/ShiftsHistory.tsx
@@ -15,6 +15,7 @@ import {
import { formatMoney, formatDuration, 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";
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import type { LedgerEvent } from "@parking/shared";
@@ -176,7 +177,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
)}
{isAdmin && (
- {t("shifts.operator")}
+ {/* {t("shifts.operator")} */}
{/* A select over operators that HAVE shifts — the server filter is an
exact username match, so free text could only miss. */}
diff --git a/apps/web/src/ui/Spinner.tsx b/apps/web/src/ui/Spinner.tsx
new file mode 100644
index 0000000..e2dfbaf
--- /dev/null
+++ b/apps/web/src/ui/Spinner.tsx
@@ -0,0 +1,13 @@
+// Inline busy indicator for buttons whose action can take a moment (opening a
+// shift signs an event over the whole chain; printing waits on hardware). A label
+// swap alone ("Opening…") proved too subtle on the booth — operators re-clicked or
+// assumed the click was lost, so busy buttons pair the text with this spinner.
+// Inherits the button's text colour via border-current.
+export function Spinner() {
+ return (
+
+ );
+}
diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md
index c50be53..fdd6c9e 100644
--- a/wiki/concepts/shift.md
+++ b/wiki/concepts/shift.md
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
-updated: 2026-07-01
+updated: 2026-07-05
status: open
---
@@ -237,6 +237,35 @@ after**. This removes the friction while keeping accountability.
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
+## Drawer hub — the page answers "what's in the till and why" (2026-07-05)
+
+Operator feedback: `/drawer` was **too simple** — record + review only, with no current balance, no
+sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
+panels, all reads over data that already existed:
+
+- **Drawer now** — the running balance (new `GET /api/drawer/balance`, `shift:read`; a passthrough to
+ the service's existing `drawerBalance()`, which was never exposed). While a shift is open, the
+ X-report breakdown sits beside it so the number is always explainable: *opening float + cash takings
+ (with payment count — the "current shift incomings") + vouchers in − out = expected = balance*, and
+ a **"This shift: ±X"** figure under the balance shows the shift's OWN contribution (expected −
+ opening float), separating what this operator moved from what they inherited. With no shift open it
+ reads as the carried-forward closing balance.
+- **Today's cash activity** — every drawer-touching signed event since local midnight (cash payments +
+ vouchers; card never enters the till), live (15s), with day totals. Source: the existing
+ `/api/events` window query, filtered client-side (`event:read`).
+- **Record** + **movements/review** — the 2026-07-01 flow, unchanged.
+- **Closed shifts** — drawer-focused history via the existing scope-aware `/api/shifts`
+ (operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
+
+Visibility note: the balance endpoint is `shift:read` on purpose — the drawer is a **single site-wide
+till**, the same exposure the open shift's X-report already had, not per-operator data.
+
+**Busy feedback on shift buttons (2026-07-05).** Opening a shift can take seconds (see the open item
+below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
+read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
+"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
+(`ui/Spinner.tsx`, reusable) and dims while disabled.
+
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
@@ -253,6 +282,13 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## Open
+- **Drawer/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).**
+ `#drawerBalanceAt`, `currentOpenShift`, and `listShifts` select every ledger event and fold in JS.
+ Fine pre-opening; after months of operation this is a linearly growing pause on every shift open,
+ X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it
+ bites: fold **from the last `shift_z_report` forward** — its `expectedDrawerMinor` is already the
+ signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy
+ spinners in the meantime.
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
diff --git a/wiki/log.md b/wiki/log.md
index ee1d1fa..ce33717 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -2320,3 +2320,25 @@ Follow-up to the lab redesign (same session): the sidebar now also lists the PUB
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
an optional version-name field (never prefilled — republishing a tweak under last season's name
would mislabel history). Details on [[tariff]] (Tariff Lab section).
+
+## [2026-07-05] update | Drawer hub: balance now, live daily activity, shift history
+
+Operator: "/drawer is too simple — no daily activity, current shift incomings not reflected, closed
+shifts history missing, drawer current state not shown." Rebuilt DrawerManager as a five-panel hub
+(details on [[shift]] §Drawer hub): Drawer-now panel (new GET /api/drawer/balance exposing the
+service's existing drawerBalance(); open-shift X-report breakdown alongside so float + takings +
+vouchers = expected = balance is explicit), today's cash feed (existing /api/events since local
+midnight, cash-only, live), the unchanged record/review flow, and a drawer-focused closed-shifts
+list (existing scope-aware /api/shifts). No new ledger surface — one read-only endpoint, everything
+else composes what the chain already records. RBAC test added (shift:read 200 / other 403 / anon 401).
+
+## [2026-07-05] update | Drawer "this shift" figure + spinners on shift buttons; O(chain) flagged
+
+Follow-ups to the drawer hub (same session): the Drawer-now panel gained "This shift: ±X"
+(expected − opening float — the shift's own contribution vs what it inherited), and every shift
+open/close button got an animated spinner + dim while busy (ui/Spinner.tsx) — the old label-swap
+read as a dead click on a slow open. Root cause of the slowness recorded as an open item on
+[[shift]]: drawer/shift reads fold the WHOLE chain (O(chain) — grows forever); fix when it bites =
+fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
+migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
+migration means it was skipped; booth containers migrate on boot and are immune.