/** * Scarcity thresholds for ticket availability badges and copy. * * THE single source of truth for the three-tier urgency model. Every scarcity * claim in venue-calendar routes here: the browse grid * (browse-widgets/status.ts), the event page + booking widget + ticket cards * (public-calendar-flow/getScarcityLevel adapts this function's enum to its * own 'none'/'sold_out' shape), the , and the ticket-selection * TicketBlock. * * Tiers use a SLIDING percent threshold so the claim scales with venue size * (Peet UX ruling 2026-07-07 — grow the canon, don't flatten to a flat * percent). The base thresholds bend toward an absolute floor/cap: * * - urgent : pct <= effective(URGENT_PCT, ABSOLUTE_FLOOR) * AND remaining <= MAX_EXACT_COUNT * -> "Only N left" with exact count + flame * - low : pct <= effective(LOW_PCT, LOW_ABSOLUTE_FLOOR) AND not urgent * -> "Few tickets left" / "Selling fast" (vague) + flame * - plenty : otherwise -> no badge * - sold-out: remaining <= 0 -> "Sold out" * * ABSOLUTE_CAP keeps big rooms honest: a 1,000-seat show at 200 left (20%) is * NOT "selling fast" — the cap forbids any claim above 30 remaining. The floors * keep small rooms proportional: a 10-seat room at 5 left (50%) genuinely is * going fast, so a floor allows the claim even at a high percent. * * THE FLOOR IS PER TIER, and that is MIC-5166. A single shared floor of 5 * pinned the urgent and the vague threshold to the SAME percent on every pool * of 25 or fewer, which left the vague tier no width at all — a 6-to-29-seat * pool went straight from no badge to "Only 5 left" and never said "Selling * fast". LOW_ABSOLUTE_FLOOR sits one seat ABOVE ABSOLUTE_FLOOR, so the vague * badge always arrives first, at 6 remaining, and the exact count follows at 5. * * The window is 6 to 29 by arithmetic, not by a second constant: the floor only * wins the max() while LOW_ABSOLUTE_FLOOR/total% > LOW_PCT, i.e. while * total < LOW_ABSOLUTE_FLOOR/LOW_PCT*100 = 6/20*100 = 30. From 30 seats up the * base 20% already put "Selling fast" at 6 remaining, so nothing there moves. * * MAX_EXACT_COUNT caps the exact-count copy: even if percent triggers urgent on * a huge inventory, we only reveal exact remaining when the absolute number is * small enough to feel like genuine scarcity rather than dump-data ("Only 487 left"). * * SCARCITY_POLICY.CLAIM_ON_UNTOUCHED_POOL answers MIC-3808 and nothing else: * whether a pool that has sold NOTHING yet may still make a claim. See that * constant's own note — it is the one line Peet's ruling moves. * * MIRRORED COPY — EDIT BOTH, AND THE MIRROR IS NOW MACHINE-CHECKED: the admin * app pins a copy of the block between the `scarcity-canon:begin` / * `scarcity-canon:end` sentinels below at * `micdrop-frontend/src/lib/foundry/settings-tickets/scarcity.ts` (it cannot * import this package at runtime — importing the package entry injects the * widget's stylesheet, defines its custom element and auto-mounts). * * This file is therefore listed in `package.json#files`: the published tarball * carries the canon's SOURCE purely so the admin app can read it back out of * `node_modules` and diff it. * `micdrop-frontend/.../__tests__/scarcity.canon-drift.test.ts` extracts the * sentinel block from both files, strips comments, and fails on any difference * — numbers OR condition. Nothing between those sentinels may diverge; move a * threshold or a comparison on EITHER side and the admin suite goes red, * because an operator's low-stock strip that contradicts the buyer's badge is * the exact defect MIC-3808 was filed about. * * Keep the sentinel block free of anything the admin app cannot compile: no * imports, no `$lib` paths, no Svelte. Narrative belongs in the comments, which * the drift check strips and which the two files are free to word differently. * * `SCARCITY_THRESHOLDS` and `computeScarcityLevel` are also re-exported from * the package entry (`src/lib/VenueCalendar.js`) and hand-declared in * `src/lib/types/index.d.ts`. */ /* --- scarcity-canon:begin --- (mirrored verbatim in micdrop-frontend; see header) */ export const SCARCITY_THRESHOLDS = { /** Base percent remaining at or below which a ticket is "urgent" (exact count). */ URGENT_PCT: 10, /** Base percent remaining at or below which a ticket is "low" (vague badge). */ LOW_PCT: 20, /** Absolute cap on exact-count copy; above this we fall back to vague badge. */ MAX_EXACT_COUNT: 20, /** Always allow the EXACT-COUNT claim at or below this many remaining (small venues). */ ABSOLUTE_FLOOR: 5, /** * Always allow the VAGUE claim at or below this many remaining — one seat * above ABSOLUTE_FLOOR, so "Selling fast" always precedes "Only N left" * (MIC-5166). */ LOW_ABSOLUTE_FLOOR: 6, /** Never claim scarcity above this many remaining, even at a low percent (large venues). */ ABSOLUTE_CAP: 30, } as const; export type ScarcityLevel = 'sold-out' | 'urgent' | 'low' | 'plenty'; /** * MIC-3808 — may a pool that has sold NOTHING yet make a scarcity claim? * * ABSOLUTE_FLOOR = 5 has a consequence nobody ever ruled on. The floor lifts * the urgent threshold to `5/total%`, so for a pool of five that threshold is * 100% — and 100% remaining is what a ticket looks like the instant it goes on * sale. A five-seat ticket therefore reads "Only 5 left" before a single seat * has moved, and so does every smaller pool: * * 1 of 1 .. 5 of 5 -> urgent ("Only N left" from minute one) * 6 of 6 -> low ("Selling fast" from minute one; MIC-5166) * 7 of 7 and larger -> plenty (no claim until seats actually sell) * * That cliff is arithmetic, not a decision. It may well be right — five or six * seats IS nearly nothing — but "scarce because it was never big" and "scarce * because it is selling" are different claims in the same words, and only one * of them is urgency. * * MIC-5166 moved the cliff by exactly one seat and deliberately did NOT answer * the question. LOW_ABSOLUTE_FLOOR = 6 means a six-seat pool now makes the * VAGUE claim at full stock where it used to make none. The ruling still * governs that, because the guard below runs before any threshold arithmetic: * rule `false` and 6 of 6 falls silent next to 1 of 1 .. 5 of 5. What `false` * cannot do is leave a six-seat pool a "Selling fast" step, because on a pool * of six "before Only 5 left" IS full stock — so the ruling costs that one * pool size its MIC-5166 badge and leaves 7 of 7 through 29-seat pools alone. * * So the behaviour is named rather than emergent, and `true` is exactly what * production does today. Peet's ruling is this one boolean: * * true (today) - a full-stock pool of <= LOW_ABSOLUTE_FLOOR claims * immediately: the exact count up to ABSOLUTE_FLOOR, the * vague badge at LOW_ABSOLUTE_FLOOR itself. * false - no claim until at least one seat has sold; the ladder is * unchanged from the first sale onward, so a 5-seat ticket * still reads "Only 4 left" the moment one goes. * * Flipping it moves the buyer's badge and the operator's low-stock strip * together, because the drift check forbids the two copies from disagreeing. * * Typed as a widened `boolean` on purpose: `as const` would narrow the field to * the literal `true` and let a type-aware lint delete the branch below as * statically dead, which would make the ruling a code change rather than a * value change. */ export interface ScarcityPolicy { readonly CLAIM_ON_UNTOUCHED_POOL: boolean; } export const SCARCITY_POLICY: ScarcityPolicy = { CLAIM_ON_UNTOUCHED_POOL: true, }; /** * Bend a base percent threshold toward the absolute floor/cap for this pool * size. Returns the percent-remaining at or below which the base tier applies. * * effective(base, floor) = max( min(base, CAP/total%), floor/total% ) * * The cap lowers the threshold on large venues (30 tickets is a small slice of * 1,000, so the % must be tiny); the floor raises it on small venues (5 tickets * is half of 10, so a high % still counts). `total` is assumed > 0 (callers * guard total <= 0 before calling). * * The floor is an ARGUMENT rather than a constant read in here, and that is the * whole of MIC-5166: the two tiers need different floors, or on a small pool * they bend to the same percent and the vague tier has no width left. */ function effectiveThreshold( basePct: number, total: number, absoluteFloor: number ): number { const { ABSOLUTE_CAP } = SCARCITY_THRESHOLDS; const capPct = (ABSOLUTE_CAP / total) * 100; const floorPct = (absoluteFloor / total) * 100; return Math.max(Math.min(basePct, capPct), floorPct); } /** * Compute the scarcity level for a (remaining, total) pair. * * Returns 'plenty' when total <= 0 (we can't compute a percentage; assume * unlimited / unknown capacity, no scarcity claim). Returns 'sold-out' * defensively for negative remaining. * * UNKNOWN CAPACITY IS CHECKED FIRST, and that ordering is the fix for MIC-2532's * second half. A hidden-only show ships NO public tickets, so the public payload * honestly reports remainingSeats 0 of totalSeats 0 — there is nothing public to * count, which is not the same as nothing left to sell. Read remaining-first, * 0-of-0 came out 'sold-out' and the event header printed a red "Sold out" badge * beside a working Get tickets button: the two-different-stories-at-once the * ticket was filed about, still there after the wire flag fixed the button. * * A genuinely sold-out show is unaffected — it has a real capacity, so * total > 0 and remaining <= 0 still lands on 'sold-out'. * * `policy` defaults to the module's SCARCITY_POLICY and exists so the * MIC-3808 branch is reachable from a test without writing to a readonly * field. Production must never pass it: a second policy at a call site is a * second ladder, which is the thing this module exists to prevent. */ export function computeScarcityLevel( remaining: number, total: number, policy: ScarcityPolicy = SCARCITY_POLICY ): ScarcityLevel { if (total <= 0) return 'plenty'; if (remaining <= 0) return 'sold-out'; // MIC-3808. `remaining >= total` and not `===`: a stale remaining count can // be reported ABOVE the pool it belongs to, and a pool that over-reports has // sold even less than nothing — it must not slip past an equality test into // a claim the ruling forbids. if (!policy.CLAIM_ON_UNTOUCHED_POOL && remaining >= total) return 'plenty'; const pct = (remaining / total) * 100; const { URGENT_PCT, LOW_PCT, MAX_EXACT_COUNT, ABSOLUTE_FLOOR, LOW_ABSOLUTE_FLOOR, } = SCARCITY_THRESHOLDS; const urgentThreshold = effectiveThreshold(URGENT_PCT, total, ABSOLUTE_FLOOR); const lowThreshold = effectiveThreshold(LOW_PCT, total, LOW_ABSOLUTE_FLOOR); if (pct <= urgentThreshold && remaining <= MAX_EXACT_COUNT) return 'urgent'; if (pct <= lowThreshold) return 'low'; return 'plenty'; } /* --- scarcity-canon:end --- */