# Demo 2: Capacity engine

**Port:** 3002 · **Postgres schemas:** `core`, `capacity` (migration slot `2026102400xxxx`) · **Services:** Supabase

## Demo moment

Three very different businesses on one engine:

- **Spin Mitte:** a week of classes as bikes × classes; each class shows free bikes out of 12; the selected class shows the floor plan with booked, held and free bikes. Booking a taken bike again is refused by the database (`23P01`).
- **Padel Ost:** courts × time for a day, 90-minute bookings in 30-minute steps; an overlapping booking on the same court is refused (`23P01`).
- **Nordwerk Projektbüro:** people × weeks with claimed and capacity hours. Allocating 20 backend hours next week fills Jonas's week (the engine picks whoever has most free hours); allocating 20 more is refused (`P0001`), because Lena has only 12 free.

Customers see a separate booking page with only free slots, a floor plan to pick a bike or court, and a hold with a countdown that hands over to the Checkout demo.

## Run

```sh
# Static (no API): engine.js + seeds in the page, claims in localStorage
python3 -m http.server 3002          # from the repository root
open http://localhost:3002/demos/capacity-engine/

# Runtime (Worker with /api/capacity; Supabase if keys are set, seed fallback otherwise)
cd runtime && npm install && npx cf dev   # then /demos/capacity-engine/

# Tests: engine, theme adapter, plugin manifest (node:test, no dependencies)
cd demos/capacity-engine && npm test

# SQL check: applies the migration twice to PGlite and compares every RPC with engine.js
cd demos/capacity-engine && npm install --no-save @electric-sql/pglite && node scripts/verify-migration.mjs
```

Pages: [`index.html`](index.html) (overview), [`internal.html`](internal.html) (staff calendar), [`public.html`](public.html) (customer booking). Add `?business=spin-mitte|padel-ost|nordwerk-office`.

## Demo script (under 60 seconds)

1. **Staff calendar, Spin Mitte.** The next class with free bikes is selected. Click a red (booked) cell, then **Book Bike n anyway**: the red banner shows `23P01 Double booking refused`.
2. **Booking page in a second tab.** Pick a day, the 18:00 class and a bike, then **Hold**. The countdown starts. The staff tab shows the bike as held (striped). It syncs between tabs in local mode and polls every 15 s in live mode.
3. **Nordwerk.** **Allocate** with the defaults (Backend hours, Auto, next week, 20 h): Jonas's cell fills to 40/40. **Allocate** again: `P0001 Over-allocation refused … most free: Lena, 12`.
4. Optional, **Padel Ost:** click a free half hour on a court, **Book walk-in**, click the new block, then **Book Court n anyway**.

**Reset demo data** (local modes) restores the seeded state. The engine log under the calendar lists every answer.

## Three ways to run

| Mode | Badge | Reads | Writes |
|---|---|---|---|
| Live: runtime + Supabase | `Live · Supabase` | `/api/capacity/…` → capacity RPCs | RPCs with the secret key, server side |
| Runtime without Supabase | `Runtime · seed fallback` | API serves seeds + seeded claims computed with `engine.js`; the page computes the same locally | API answers `503 storage_unavailable`; the page keeps claims in this browser |
| Static files | `Static · engine.js + seeds` | `engine.js` in the page with the seed JSON | Claims in `localStorage`, synced between tabs |

If a live call fails mid-demo (network, 503, schema not exposed), the page switches to local claims and says so in a notice. Engine refusals (`23P01`, `P0001`, `22023`, `55000`, `P0002`) are shown, never treated as outages.

## Files

| Path | What |
|---|---|
| [`engine.js`](engine.js) | Engine core, no dependencies (browser, Worker, Node). The six RPCs with the contract's names and argument names, `list_claims`, `release_expired_holds`, `demoClaims()` (same pattern as the SQL seed), `createStubClient()` with the supabase-js `{ data, error }` shape. Types in [`engine.d.ts`](engine.d.ts). |
| [`data.js`](data.js) | Page data layer: API with fallback to the local engine; localStorage persistence and tab sync |
| [`business-theme.js`](business-theme.js) | Maps a business config's `theme` onto the shared design system (`applyTheme`, `toSharedTheme`); picks approved font stacks and darkens colors below 4.5:1 |
| [`views.js`](views.js), [`styles.css`](styles.css) | Floor plan, notices and legends; `cap-*` styles on top of `components.css` |
| [`internal.js`](internal.js), [`public.js`](public.js) | Staff calendar and booking page |
| [`test/`](test) | `node:test` suites |
| [`scripts/verify-migration.mjs`](scripts/verify-migration.mjs) | PGlite check of the migration against `engine.js` |
| [`../showcase/plugins/capacity.js`](../showcase/plugins/capacity.js) | Compact workspace plugin `capacityPlugin` (not registered yet: add `registry.register(capacityPlugin)` in `workspace.js`) |
| [`../../runtime/src/routes/capacity.ts`](../../runtime/src/routes/capacity.ts) | Runtime API |
| [`../../supabase/migrations/20261024000000_capacity_engine.sql`](../../supabase/migrations/20261024000000_capacity_engine.sql) | Schema, RPCs, seeds |

## Runtime API

All responses are `{ data, source }` (`supabase` or `seed`). Errors are `{ error: { code, message } }`: `23P01`, `P0001` and `55000` → 409, `P0002` → 404, `22023` → 400, storage → 503.

| Route | RPC |
|---|---|
| `GET /api/capacity/businesses` | `core.businesses` (seed fallback) → `{ businesses, source }` |
| `GET /api/capacity/:business/slots?offering=&from=&to=` | `available_slots` |
| `GET /api/capacity/:business/utilisation?pool=&from=&to=` | `pool_utilisation`; without `pool`, every pool with a quantity offering, each row tagged with `pool` |
| `GET /api/capacity/:business/claims?from=&to=` | Active claims for the staff calendar (tables, secret key) |
| `POST /api/capacity/holds` `{ p_business, p_offering, p_start, p_resource, p_ref }` | `place_hold` → claim id |
| `POST /api/capacity/claims/:id/confirm` | `confirm_claim` → status |
| `POST /api/capacity/claims/:id/release` | `release_claim` → status |
| `POST /api/capacity/allocations` `{ p_business, p_offering, p_resource, p_period_start, p_hours, p_ref }` | `allocate` → claim id |

## Database

[`20261024000000_capacity_engine.sql`](../../supabase/migrations/20261024000000_capacity_engine.sql) is idempotent (`if not exists`, `create or replace`, `on conflict`). It has **not been applied** yet. To apply it:

1. Run the file in the SQL editor or with `supabase db push`. It creates `extensions.btree_gist`, `core.businesses` (three seed configs), `capacity.interval_claims`, `capacity.quantity_claims`, the RPCs and the seeded demo claims.
2. Add `core` and `capacity` to **Settings → API → Exposed schemas**.
3. Enable `pg_cron` if it is not on yet. The migration schedules `capacity-release-expired-holds` every minute when the extension is available. Without it, `place_hold` releases expired holds lazily and availability ignores them anyway.
4. Before the demo, re-anchor the busy pattern on today with the secret key: `select capacity.seed_demo_claims();`. Live claims are kept.

- **Interval claims:** `during tstzrange` with `exclude using gist (business with =, resource with =, during with &&) where (status in ('pending','confirmed'))`. `place_hold` turns the exclusion violation into a readable message with code `23P01`.
- **Quantity claims:** `allocate` takes an advisory transaction lock per member and period, checks claimed + new ≤ capacity and raises `P0001`. Capacity is `hours_per_week` scaled by the open days in the period, so a closed day (calendar exception) lowers it.
- **RLS** is on every table. `core.businesses` is readable by everyone; claims have no anon policies. RPCs are `security definer`. `available_slots` and `pool_utilisation` can be executed by `anon`; the writes and `seed_demo_claims` only by `service_role`.

## Semantics worth knowing (other demos)

- `available_slots` returns **every scheduled slot** in `[p_from, p_to)`, including full ones (`free_units = 0`), so staff views can show "Full". Customer pages filter `free_units > 0` and apply `min_notice_min`.
- `place_hold` enforces `min_notice_min`, `max_advance_days` and the schedule (`22023`), and refuses taken units with `23P01`. With `p_resource = null` it takes the first free unit in floor plan order.
- `confirm_claim` and `release_claim` return the resulting status as text. Release is idempotent. Confirming an **expired** hold succeeds if the unit is still free (late Checkout webhooks); otherwise it raises `23P01`. Confirming a released claim raises `55000`.
- `release_claim` also cancels quantity claims.
- Times come back as ISO 8601: the engine uses the business offset (`+01:00`), PostgREST uses UTC. Parse them with `Date.parse`; do not compare strings.
- `units_per_booking > 1` is not handled. Every hold claims one unit (all seeds use 1).
- For the Checkout demo: import `engine.js` and use `createStubClient(createEngine({ businesses }))` until the RPCs are live.

## Simulated

- The seeded claims make the grids look busy. The pattern is identical in `engine.js` and SQL, anchored on today (Berlin): one week back and two weeks ahead.
- In local modes, claims live in the browser only. Another device does not see them.
- **Continue to payment** is a placeholder link to `../booking-checkout/?business=…&offering=…&claim=…&start=…&resource=…&amount=…&currency=…` (`CHECKOUT_URL` in `public.js`).
- There is no auth: the staff calendar is open, and the runtime reads claims with the secret key for it.

## Done when

- [x] The migration applies on a fresh database (verified twice in a row on PGlite) and loads the three seed businesses.
- [x] All six RPCs work with the argument shapes in the contract. SQL and `engine.js` return identical slots, free units and utilisation, including across the DST change on 25 October.
- [x] A double booking returns `23P01`; an over-allocation returns `P0001`.
- [x] An expired hold stops blocking at once (availability, `place_hold`). `pg_cron` marks it expired within a minute once the extension is enabled on the hosted project.
- [ ] Applied to the hosted Supabase project (needs database credentials).

## Out of scope

Recurring exceptions beyond the seed, multiple time zones, cancellation fees, `pg_jsonschema` validation of configs, Realtime.
