import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db"; // Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events // with no matching vehicle_exit. Never a hand-maintained counter (which is // editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md. export interface Occupancy { /** Cars currently inside (open sessions). */ readonly count: number; /** Spots HELD for active subscribers who are NOT currently parked (when the * reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds * `quantity` spots minus however many of its cars are already inside. */ readonly reserved: number; /** Admin-set nominal capacity, or null = no limit. */ readonly capacity: number | null; /** capacity − count, or null when uncapped. Can read 0 (or below) when full. */ readonly free: number | null; /** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */ readonly effectiveFree: number | null; /** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity * (always false when uncapped). Subscribers are never gated by this. */ readonly full: boolean; } /** Count cars inside: entries minus exits, per identity, over the ledger. */ export function occupancyCount(db: Db): number { const rows = db .select({ type: ledgerEvents.type, identity: ledgerEvents.identity }) .from(ledgerEvents) .all(); const balance = new Map(); for (const r of rows) { // A `void` (cancelled ticket) closes the session like an exit — the car never entered // (misprint), so it must not count inside. See void-flow.ts. if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1); else if (r.type === "vehicle_exit" || r.type === "void") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1); } let open = 0; for (const v of balance.values()) if (v > 0) open += 1; return open; } /** Admin-set capacity (null = uncapped). */ export function siteCapacity(db: Db): number | null { const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); return row?.capacity ?? null; } /** * Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless * `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status * active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that * subscription already inside (so we never double-count a parked subscriber). This is * what makes a transient see "full" sooner while the subscriber's spot is held. */ export function reservedSubscriberSpots(db: Db): number { const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (!cfg?.reserveSubscriberSpots) return 0; // Cars currently inside per subscription (occurrence entries by permitId, net of exits). const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const insidePerSub = new Map(); const net = new Map(); // occurrence identity → entries−exits const subOf = new Map(); // occurrence identity → subscription id for (const r of rows) { const id = r.identity; if (!id) continue; if (r.type === "vehicle_entry") { const pl = (r.payload ?? {}) as { permitId?: string }; if (pl.permitId == null) continue; // transient net.set(id, (net.get(id) ?? 0) + 1); subOf.set(id, pl.permitId); } else if (r.type === "vehicle_exit" || r.type === "void") { if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1); } } for (const [id, n] of net) if (n > 0) { const sub = subOf.get(id)!; insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1); } const now = new Date().toISOString(); const subs = db.select().from(subscriptions).all(); let reserved = 0; for (const s of subs) { const active = s.status === "active" && (s.validFrom == null || now >= s.validFrom) && (s.validTo == null || now <= s.validTo); if (!active) continue; const qty = s.quantity ?? 1; const inside = insidePerSub.get(s.id) ?? 0; reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion } return reserved; } export function getOccupancy(db: Db): Occupancy { const count = occupancyCount(db); const capacity = siteCapacity(db); const reserved = reservedSubscriberSpots(db); return { count, reserved, capacity, free: capacity == null ? null : capacity - count, effectiveFree: capacity == null ? null : capacity - count - reserved, // A transient is refused once physical cars + held subscriber spots reach capacity. full: capacity != null && count + reserved >= capacity, }; }