diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac2ae6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '24' + +jobs: + secret-scan: + name: Secret scan + runs-on: ubuntu-latest + container: + image: ghcr.io/gitleaks/gitleaks:v8.30.1 + options: --entrypoint '' + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - run: gitleaks git --config .gitleaks.toml --redact --verbose . + + dependency-audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run security:audit + + quality: + name: Quality + needs: [secret-scan, dependency-audit] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run quality + + commitlint: + name: Commit lint + needs: [secret-scan, dependency-audit] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Lint commit range + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BEFORE_SHA: ${{ github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -eu + if [ -n "${BASE_SHA:-}" ]; then + npx --no -- commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --verbose + elif [ -n "${BEFORE_SHA:-}" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + npx --no -- commitlint --from "$BEFORE_SHA" --to "$HEAD_SHA" --verbose + else + git fetch origin "$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH" + BASE="$(git merge-base "$HEAD_SHA" "origin/$DEFAULT_BRANCH")" + if [ "$BASE" = "$HEAD_SHA" ]; then + git log -1 --format=%B "$HEAD_SHA" | npx --no -- commitlint --verbose + else + npx --no -- commitlint --from "$BASE" --to "$HEAD_SHA" --verbose + fi + fi + + test: + name: Test + needs: [quality, commitlint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + build: + name: Build + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build diff --git a/.gitignore b/.gitignore index a547bf3..abfdc49 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,23 @@ node_modules dist dist-ssr *.local +coverage +.vite/ +.npm/ + +.env +.env.* +!.env.example + +src/generated/ +public/generated/ +*.generated.* # Editor directories and files .vscode/* !.vscode/extensions.json .idea +.claude/ .DS_Store *.suo *.ntvs* diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..5844738 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "Interactive Event Seating Map secret scanning" + +[extend] +useDefault = true diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..da99483 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..f80fd4a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +if ! command -v gitleaks >/dev/null 2>&1; then + echo "gitleaks 8.30.x is required before committing." >&2 + exit 1 +fi + +git diff --cached --no-ext-diff --unified=0 | gitleaks stdin --config .gitleaks.toml --redact --no-banner +npx lint-staged +pnpm run quality +pnpm run build diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c20257b --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +engine-strict=true +save-exact=true +audit=true +fund=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..43269ef --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +dist/ +coverage/ +node_modules/ +.claude/ + +src/generated/ +public/generated/ +**/*.generated.* + +package-lock.json +npm-shrinkwrap.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +public/venue.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..642543d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "arrowParens": "always", + "bracketSameLine": false, + "endOfLine": "lf", + "printWidth": 100, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/README.md b/README.md index f8a6410..eae427b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,411 @@ # Event Seating Map -A high-performance, interactive seating map for large venues. Built to handle 15,000+ seats with smooth performance and a premium user experience. +[![Node Version](https://img.shields.io/badge/node-%3E%3D24%20%3C25-339933?logo=node.js&logoColor=white)](https://nodejs.org) +[![React Version](https://img.shields.io/badge/react-19.2-149ECA?logo=react&logoColor=white)](https://react.dev) +[![pnpm](https://img.shields.io/badge/pnpm-package%20manager-F69220?logo=pnpm&logoColor=white)](https://pnpm.io) -##Quick Start +A high-performance, interactive seating map for large venues, built to handle **15,000+ seats** +with smooth panning, zooming, keyboard navigation, and persisted selection - without shipping a +canvas/WebGL engine or a virtualization library. -Get the project running in seconds: +--- -1. **Install dependencies:** +## 🎯 Problem statement + +A venue booking UI has to render every seat in a venue (this project's sample data: 10 sections Γ— +30 rows Γ— 50 seats = **15,000 seats**) as an individually selectable, keyboard-navigable element, +while staying responsive to pan, zoom, and selection changes. Rendering 15,000 live SVG nodes at +once is the naive approach - and it's slow: every zoom/selection change forces the browser to +diff, layout, and paint tens of thousands of DOM nodes. This project's core engineering problem is +keeping the map interactive at that scale using nothing heavier than React + SVG. + +## πŸ“Έ Screenshots + +**Overview** - the full venue map with all 10 sections collapsed, and the Booking History sidebar +in its default state. + +![Venue overview](docs/screenshots/overview.png) + +**Section drill-in & seat selection** - every seat status (available, selected, sold, reserved, +held) visible at once, with the live selected-seats list and running total in the sidebar. + +![Seat selection](docs/screenshots/seat-selection.png) + +**Booking History** - completed purchases are grouped and listed; clicking one highlights its +exact seats back on the map. + +![Booking history with a highlighted past booking](docs/screenshots/booking-history.png) + +--- + +## πŸ—οΈ Architecture & performance + +```mermaid +flowchart TD + A[App] --> B["useVenue()
fetch /venue.json"] + A --> C[VenueMap] + C --> D["Section Γ— 10
(memoized)"] + D -->|inactive| E["Collapsed <path> cover shape
(1 SVG node per section)"] + D -->|active| F["Seats
(memoized)"] + F --> G["~1,500 <rect> seat nodes
(one section's worth)"] + A --> H["seatStore (Zustand)
selection Β· holds Β· zoom Β· active section"] + H -.->|persist selectedSeats + deadline| I[(localStorage)] + F -.reads/writes.-> H + A --> J[Toast
aria-live feedback] + H -.->|feedback| J + A --> K["HoldRuntime
(renders nothing)"] + K -.expiry timer.-> H + K <-.BroadcastChannel.-> L["Other tabs"] +``` + +### 15,000+ seat rendering: section-level collapsing + +The map never mounts all 15,000 seats as DOM nodes at once. [`Section.tsx`](src/components/Section.tsx) +renders each **inactive** section as a single collapsed `` "cover" shape traced around its +outer row (one SVG node, regardless of how many seats it contains). Only the **one active** +section (the one the user clicked into) mounts individual seat `` nodes via +[`Seat.tsx`](src/components/Seat.tsx). That caps live, interactive seat nodes at ~1,500 (one +section) instead of 15,000, at any given time. + +This is a deliberate alternative to two heavier options: + +- **Canvas/WebGL**: gives raw rendering throughput but throws away native SVG accessibility + (focus, `role`, `aria-*`) and hit-testing - you'd have to reimplement both by hand. +- **Row/column virtualization** (`react-window`/`react-virtualized`): built for linear lists, not + an SVG coordinate space with seats laid out on radial rows. Section-level collapsing matches this + venue's natural UX unit (users think in sections, not in a scrollable seat list) and needs no + extra dependency. + +`Section` and `Seats` are both `React.memo`-wrapped, so panning, zooming, or selecting a seat in +one section doesn't force every other (already-collapsed) section to re-render or recompute its +cover path. + +### Zustand persistence strategy + +Selected seats survive a page refresh via Zustand's `persist` middleware - but `selectedSeats` is +a `Set`, and `JSON.stringify` can't round-trip a `Set` on its own. [`seatStore.ts`](src/store/seatStore.ts) +supplies a custom `PersistStorage` that serializes `Set ↔ string[]` on `getItem`/`setItem`, +so `localStorage` only ever stores an array, while the in-memory store keeps `O(1)` `has()`/`add()`/ +`delete()` lookups for selection toggling. `partialize` scopes persistence to `selectedSeats` only - +`zoom`, `activeSectionId`, and transient `feedback` state are intentionally not persisted. + +See [`docs/engineering-notes.md`](docs/engineering-notes.md) for the tradeoffs behind these +decisions and where they'd need to change at greater scale. + +### Best-seats solver + +[`findBestSeats.ts`](src/utils/findBestSeats.ts) finds the single best contiguous block of N seats +for a party in a single pass over the venue (O(seats Γ— party size)), ranked by a strict, +deterministic total order - "Best view" sorts by row distance from the stage, then how centered the +block is _within its row's own `col` range_ (not x/y - see the comment in that file for why arc +position, not geometry, is the right unit), then price; "Best price" sorts the same way but leads +with price. The last two keys (section, starting seat) exist purely to break ties, so the same +query always returns the same seats rather than depending on `venue.json`'s array order. +[`BestSeatsFinder.tsx`](src/components/BestSeatsFinder.tsx) runs it against live availability +(`venue.json` status, seats sold this session, and any live hold - see below) and focuses/selects +the winning block in one atomic store update. + +### Live seat-hold contention + +Real ticketing sites don't just mark seats "held" once and forget - holds expire, and two people +(or two tabs) can race for the same seat. This project simulates that without a backend: + +- **A checkout timer.** Selecting a seat starts a 5-minute deadline ([`seatStore.ts`](src/store/seatStore.ts)'s + `selectionExpiresAt`, persisted so a refresh doesn't grant an unlimited hold). It's surfaced as + [`CheckoutTimer.tsx`](src/components/CheckoutTimer.tsx), driven by [`useCountdown.ts`](src/hooks/useCountdown.ts) - + the countdown owns its own render loop and never touches the store on a tick, so it can't cause + the seat layer to re-render. +- **The sample `held` seats actually expire.** [`holdSimulation.ts`](src/utils/holdSimulation.ts) + seeds every JSON-`held` seat with a real, staggered expiry at runtime (never baked into the + committed file, which would just be stale on the next load) using a deterministic hash of the + seat id - reproducible, but spread across a window so seats visibly release a few at a time + instead of all at once. +- **Cross-tab sync.** [`holdChannel.ts`](src/utils/holdChannel.ts) wraps `BroadcastChannel` behind + an injectable, feature-detected transport (it degrades to a no-op, single-tab mode when the API + is unavailable - which is also exactly the situation under Vitest/jsdom, so the same code path is + what the tests exercise). [`holdProtocol.ts`](src/utils/holdProtocol.ts) has the actual protocol + as pure, unit-tested reducers: a join-time handshake, full-set-replacement hold messages (so a + dropped packet self-heals on the next change instead of leaving a stale hold), and a + deterministic tie-break (lower session id wins) for the case where two tabs select the same seat + in the same instant. +- **One resolver, one source of truth.** [`seatStatus.ts`](src/utils/seatStatus.ts) centralizes + `sold` β†’ `held` β†’ `selected` β†’ `reserved` β†’ `available` precedence into a single pure function, + replacing what used to be scattered boolean checks in `Seat.tsx`. +- **Never re-renders the seat layer on a timer.** Pruning a lapsed hold (`pruneHolds` in + `holdProtocol.ts`) returns the _same_ Map reference when nothing has actually expired, so a + routine tick with nothing to do costs zero re-renders across the ~1,500 mounted seats - verified + by a reference-equality (`toBe`) test, not just a behavioral one. + +--- + +## β™Ώ Accessibility + +- Every seat is a real interactive element: `role="checkbox"`, `aria-checked` reflecting selection, + `aria-label` describing row/seat/price/status, and `aria-disabled` for sold/reserved/held seats. +- **Roving keyboard navigation**: only the active section's seats are focusable (`tabIndex={0}`, + or `-1` when unavailable). Arrow keys move focus seat-to-seat - `ArrowLeft`/`ArrowRight` within a + row, `ArrowUp`/`ArrowDown` to the nearest seat (by x-position) in the adjacent row - and + `Enter`/`Space` toggle selection, matching native checkbox semantics. +- Feedback that used to be a blocking `alert()` (hitting the 8-seat cap, the payment stub) is now + an `aria-live="polite"` [`Toast`](src/components/Toast.tsx) region and inline confirmation state, + so screen readers announce it without interrupting keyboard flow. + +--- + +## πŸš€ Features + +- **Large-scale rendering**: Thousands of seats render smoothly using SVG optimization. +- **Best-seats solver**: Pick a party size and priority ("Best view" or "Best price") and find the + single best contiguous block of adjacent seats, ranked by a deterministic algorithm. +- **Live seat-hold contention**: Selecting seats starts a real 5-minute checkout timer; the sample + `held` seats expire and release on their own; two browser tabs share holds and sales live via + `BroadcastChannel`, with a deterministic tie-break if they pick the same seat at once. +- **Map navigation**: Click-and-drag panning with smart zoom in/out controls. +- **Accessibility**: Full arrow-key navigation between seats, keyboard-selectable seats (`role="checkbox"`). +- **Persisted selection**: Selected seats survive a page refresh (Zustand + `localStorage`). +- **Responsive layout**: A collapsible booking sidebar on mobile, side-by-side on desktop. + +--- + +## πŸ› οΈ Tech Stack + +### Application + +- **Core**: React 19, TypeScript +- **State Management**: Zustand (with persisted seat selection) +- **Styling**: Tailwind CSS +- **Build Tool**: Vite + +### Engineering standards & tooling + +- **Testing**: Vitest + Testing Library (jsdom) +- **Linting/Formatting**: ESLint (React, hooks, jsx-a11y, strict + stylistic type-checked + TypeScript rules) + Prettier +- **Git hooks**: Husky (`pre-commit` β†’ Gitleaks + lint-staged + full quality check + production + build, `commit-msg` β†’ commitlint) +- **Commit convention**: Conventional Commits, enforced by commitlint +- **Secret scanning**: Gitleaks (local pre-commit + full-history scan in CI) +- **CI**: GitHub Actions (`secret-scan`, `dependency-audit`, `quality`, `commitlint`, `test`, `build`) + +See [Engineering standards](#-engineering-standards) below for details. + +--- + +## πŸ“‚ Repository Structure + +```text +β”œβ”€β”€ docs/ +β”‚ β”œβ”€β”€ engineering-notes.md # Tradeoffs, performance bottlenecks, known gaps +β”‚ β”œβ”€β”€ ui-ux-and-business-logic-flow.md # UI state/interaction reference +β”‚ └── screenshots/ # README screenshots +β”œβ”€β”€ public/ +β”‚ β”œβ”€β”€ venue.json # Generated seating data served to the client +β”‚ β”œβ”€β”€ robots.txt +β”‚ └── llms.txt +β”œβ”€β”€ scripts/ +β”‚ └── generateVenue.ts # Generates public/venue.json +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ components/ # VenueMap, Section, Seat, BookingSummary, SeatSelectionPanel, +β”‚ β”‚ # SeatSelectionFooter, BookingHistoryPanel, BestSeatsFinder, +β”‚ β”‚ # CheckoutTimer, HoldRuntime, ConfirmDialog, Toast, Icon, IconButton +β”‚ β”œβ”€β”€ hooks/ +β”‚ β”‚ β”œβ”€β”€ useVenue.ts # Fetches and validates public/venue.json +β”‚ β”‚ β”œβ”€β”€ useCountdown.ts # Self-correcting per-second countdown, isolated from the store +β”‚ β”‚ β”œβ”€β”€ useHoldExpiry.ts # Arms one timer for the next hold/selection deadline +β”‚ β”‚ └── useHoldSync.ts # Cross-tab BroadcastChannel wiring +β”‚ β”œβ”€β”€ store/ +β”‚ β”‚ └── seatStore.ts # Zustand store (zoom, active section, selection + checkout +β”‚ β”‚ # deadline, holds, sold seats, booking history, feedback) +β”‚ β”œβ”€β”€ interfaces/ +β”‚ β”‚ └── venue.interfaces.ts # Venue/seat types + the hold protocol's message types +β”‚ β”œβ”€β”€ utils/ # rowLetter, seatIndex, venueBounds, viewTransform (pan/zoom math), +β”‚ β”‚ # findBestSeats (best-seats solver), seatStatus (status resolver), +β”‚ β”‚ # holdProtocol/holdChannel/holdSimulation/sessionId/formatCountdown +β”‚ β”œβ”€β”€ test/ +β”‚ β”‚ β”œβ”€β”€ setup.ts # Vitest + Testing Library setup +β”‚ β”‚ └── fakeBroadcastChannel.ts # In-memory multi-tab bus for testing the hold protocol +β”‚ β”œβ”€β”€ App.tsx +β”‚ └── main.tsx +β”œβ”€β”€ .github/workflows/ci.yml # CI pipeline +β”œβ”€β”€ .husky/ # pre-commit / commit-msg hooks +β”œβ”€β”€ .nvmrc / .npmrc # Node/pnpm version + install policy +└── README.md +``` + +--- + +## βš™οΈ Getting Started + +### Prerequisites + +- **Node.js** `>=24 <25` (see `.nvmrc` / `engines` in `package.json`) +- **pnpm 11.x** (this project uses pnpm, not npm/yarn - pinned exactly via the `packageManager` + field in `package.json`; `.npmrc` sets `engine-strict=true` so installs fail fast on a + mismatched Node/pnpm version instead of silently drifting) +- **Gitleaks v8.30.1** on `PATH` - required by the pre-commit hook (see [Secret scanning](#secret-scanning-gitleaks)) + +### Installation & Setup + +1. **Clone the repository** + + ```bash + git clone git@github.com:webvoltz/Interactive-Event-Seating-Map.git + cd Interactive-Event-Seating-Map + ``` + +2. **Install dependencies** + + ```bash pnpm install + ``` + +3. **Install the Husky git hooks** (one-time, after cloning) + + ```bash + pnpm run prepare + ``` + +### Running Locally + +```bash +pnpm dev +``` + +The app expects seat data at `public/venue.json`; a sample file is already checked in. Regenerate it any time with: + +```bash +pnpm run generate:venue +``` + +### Running a Production Build + +```bash +pnpm run build +pnpm start +``` + +`pnpm start` (an alias for `vite preview`) serves the `dist/` output from `pnpm run build` - use it +to sanity-check the actual production bundle, or to satisfy hosting platforms that expect a +`start` script. + +--- + +## πŸ”’ Environment Variables + +There are **no environment variables** - seating data is served from the static `public/venue.json`, not an API. If a real backend is introduced later, add a Zod-validated env module rather than reading `import.meta.env` directly in components. + +--- + +## πŸ§ͺ Running Tests + +```bash +pnpm test +``` + +Runs with V8 coverage enabled (`pnpm run security:audit` covers dependency vulnerabilities +separately). Coverage thresholds in [`vite.config.ts`](vite.config.ts) are set just under today's +actual numbers as a regression floor, not the org standard's targets (branches 85% / functions +100% / lines 90% / statements 90%) - most components still don't have tests; see +[`docs/engineering-notes.md`](docs/engineering-notes.md#known-remaining-gaps) for the honest +current numbers and what closing that gap actually requires. + +Existing tests cover seat selection/deselection, the 8-seat selection cap, clearing the selection, +the persisted-seats/sold-seats/booking-history/checkout-deadline `localStorage` round trip, and +keyboard navigation (arrow keys, Enter/Space, skipping unavailable seats) - see +[`src/store/seatStore.test.ts`](src/store/seatStore.test.ts) and +[`src/components/Seat.test.tsx`](src/components/Seat.test.tsx). Pan/zoom math (fit-to-screen, +zoom-at-pointer, pan clamping) is covered separately in +[`src/utils/viewTransform.test.ts`](src/utils/viewTransform.test.ts). The best-seats solver's +ranking/tiebreak logic, the hold protocol's reducers (including a reference-equality test proving a +no-op prune never triggers a re-render), the runtime hold simulation's determinism, and the +checkout countdown's stable-announcement-band behavior each have their own dedicated, near-100%-covered +test files under `src/utils/` and `src/hooks/`. + +--- + +## πŸ“ Engineering standards + +This project follows the Webvoltz React engineering standards. + +### Code quality + +- **ESLint** (`eslint.config.js`) lints `.js/.jsx/.ts/.tsx` with `eslint-plugin-react`, + `eslint-plugin-react-hooks`, `eslint-plugin-jsx-a11y`, and `typescript-eslint`'s + `strictTypeChecked` + `stylisticTypeChecked` rule sets - `no-explicit-any`, every `no-unsafe-*` + rule, `only-throw-error`, `require-await`, `switch-exhaustiveness-check`, `ban-ts-comment` + (`@ts-expect-error` requires a real description), and `consistent-type-imports` are all errors, + not warnings. `restrict-template-expressions` allows `number` (seat coordinates/prices are + legitimately interpolated) but still disallows objects/`null`/`undefined`. `pnpm run lint` fails + on any warning (`--max-warnings=0`). +- **Prettier** (`.prettierrc.json`) is the single source of formatting truth. Run + `pnpm run format`, or let the pre-commit hook format staged files for you. +- **TypeScript** runs in strict mode plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + `noImplicitReturns`, `noImplicitOverride`, `noPropertyAccessFromIndexSignature`, + `forceConsistentCasingInFileNames`, `allowUnreachableCode: false`, and `allowUnusedLabels: false`, + guarding indexed access explicitly instead of asserting it away. `skipLibCheck` stays `true` + (rather than the standard's `false`) as a documented exception: flipping it surfaces type + conflicts inside vitest/vite/testing-library's own bundled declarations, not this project's own + code (see the comment in `tsconfig.app.json`). +- `pnpm run quality` runs format-check, lint, and typecheck together - this is what CI runs, and + what the pre-commit hook runs on the whole project (not just staged files) before every commit. +- `pnpm run security:audit` runs `pnpm audit --audit-level high` for dependency vulnerabilities. + +### Git hooks (Husky) + +Installed via `pnpm run prepare`. Two hooks live in `.husky/`: + +- **`pre-commit`** - in order: scans the staged diff with Gitleaks, runs `lint-staged` + (ESLint `--fix` + Prettier) on the files you're committing, then `pnpm run quality` and + `pnpm run build` against the **whole project** (not just staged files) - a broken build or a + type error anywhere blocks the commit, even in a file you didn't touch. +- **`commit-msg`** - runs `commitlint` against your commit message. + +Don't bypass either hook with `--no-verify` to get past a genuine failure - fix the issue instead. + +### Commit messages + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/), checked by `commitlint.config.cjs`: + +- Header, body, and footer lines must each be ≀ 1000 characters - room to actually explain + yourself, not the old 72/100-character convention that just trains people to write useless + messages to fit. +- `type` must be one of: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. + +Example: `fix: prevent seat selection past the 8-seat limit` + +### Secret scanning (Gitleaks) + +The pre-commit hook requires the `gitleaks` binary on `PATH` and **fails closed** if it's missing: + +1. Download [Gitleaks v8.30.1](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) for your platform. +2. Verify it per your usual tooling-verification process and put the `gitleaks` executable on `PATH`. +3. Confirm `gitleaks version` works. + +GitHub Actions independently re-scans full repository history with the same pinned version, so a bypassed local hook is still caught in CI. + +**False positives:** investigate every finding first. Only add a narrowly scoped exception in `.gitleaks.toml` after a real secret has been ruled out, with a comment explaining why - never a blanket allowlist - and re-run the scan afterward. + +### Continuous integration -2. **Generate venue data (Optional):** - npx ts-node scripts/generateVenue.ts +`.github/workflows/ci.yml` runs on every push and pull request, gated in stages via `needs`: -3. **Start development server:** - pnpm dev +| Job | Needs | What it does | +| ------------------ | --------------------------------- | ------------------------------------------------------------------------------ | +| `secret-scan` | - | Gitleaks over full repository history. | +| `dependency-audit` | - | `pnpm run security:audit` (`pnpm audit --audit-level high`). | +| `quality` | `secret-scan`, `dependency-audit` | `pnpm run quality` (format check, lint, typecheck). | +| `commitlint` | `secret-scan`, `dependency-audit` | Lints the commit range being pushed/merged. | +| `test` | `quality`, `commitlint` | `pnpm test` (V8 coverage, checked against the thresholds in `vite.config.ts`). | +| `build` | `test` | `pnpm run build`. | -## Key Features +All jobs must pass before merging. -- Handles thousands of seats smoothly using SVG optimization. -- Intuitive click-and-drag panning and smart zoom. -- Full support for arrow-key navigation between seats. -- Remembers your selected seats even after a page refresh. -- Works beautifully on both desktop and mobile devices. +--- -## Tech Stack +## 🀝 Contributing -- **React + TypeScript** – Core logic and type safety. -- **Tailwind CSS** – Modern, responsive styling. -- **Zustand** – Lightweight and fast state management. -- **Vite** – Lightning-fast build tool. +1. Fork or branch from `main`. +2. Create your feature branch (`git checkout -b feature/amazing-feature`). +3. Commit your changes following the [commit convention](#commit-messages) (`git commit -m 'feat: add amazing feature'`). +4. Run `pnpm run quality && pnpm test && pnpm run build` before pushing. +5. Push to the branch and open a pull request. diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..85b67a3 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,25 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'header-max-length': [2, 'always', 1000], + 'body-max-line-length': [2, 'always', 1000], + 'footer-max-line-length': [2, 'always', 1000], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + }, +}; diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md new file mode 100644 index 0000000..e1fa31d --- /dev/null +++ b/docs/engineering-notes.md @@ -0,0 +1,134 @@ +# Engineering notes + +This document records the tradeoffs behind the seating map's performance-critical decisions, and +the bottlenecks they leave in place. It's aimed at anyone extending this codebase who needs to know +_why_ something works the way it does before changing it. + +## Why section-level collapsing instead of a virtualization library + +`react-window`/`react-virtualized` virtualize a linear (or grid) list by only mounting the rows +currently in the scroll viewport. That model doesn't map cleanly onto this venue: seats are laid +out on radial rows around a stage (see [`scripts/generateVenue.ts`](../scripts/generateVenue.ts)), +not a scrollable list, and the interaction unit users care about is a **section**, not a row. + +Instead, [`Section.tsx`](../src/components/Section.tsx) renders each inactive section as a single +collapsed `` and defers mounting any seat nodes until the user clicks into it. This is simpler +than adapting a list-virtualization library to a 2D radial layout, needs no extra dependency, and +naturally matches the venue's real-world browsing pattern (pick a section, then pick a seat). + +**Bottleneck this leaves in place:** the _active_ section still mounts all of its seats at once +(~1,500 in the current 10Γ—30Γ—50 sample venue). That's fine at this scale, but a venue with, say, +5,000 seats in a single section would need row-level virtualization _within_ the active section - +this codebase doesn't have that yet. + +## Why SVG over Canvas/WebGL + +SVG gives native accessibility (`role`, `aria-*`, real DOM focus) and hit-testing for free - every +seat is a real, individually focusable element without any custom hit-testing code. Canvas/WebGL +would render faster at very large scale, but every one of those things (focus order, screen-reader +semantics, click targets) would need to be reimplemented by hand against an off-screen model. + +**Where this stops scaling:** SVG's per-node overhead (DOM presence, style recalculation, event +listener) becomes the bottleneck somewhere in the tens-of-thousands of simultaneously-mounted +nodes - this project stays well under that by only ever mounting one section's seats. If a future +requirement needs _all_ sections' seats interactive simultaneously (e.g., a full-venue heatmap), +that's the point to reach for Canvas with a manual hit-testing layer, or a WebGL instanced-rendering +approach, accepting the accessibility rebuild that comes with it. + +## `memo()` boundaries and render cost + +`Section` and `Seats` (in [`Seat.tsx`](../src/components/Seat.tsx)) are both `React.memo`-wrapped so +that panning/zooming or selecting a seat in one section doesn't re-render every other section's +already-collapsed cover path. Per-seat fill/stroke/text-color used to be recomputed via an if/else +ladder on every render; [`seatStatus.ts`](../src/utils/seatStatus.ts)'s `SEAT_STATUS_STYLE` replaced +that with a lookup keyed by effective status, computed once at module scope. The one thing that +lookup can't express is that an _available_ seat's stroke also depends on its own price tier - +`Seat.tsx` still applies that as a small override after the lookup. + +A second render-cost fix landed alongside the live-hold work: `Seats` used to subscribe to the raw +`zoom` number just to derive a `showLabels` boolean, which meant all ~1,500 mounted seats re-rendered +on _every_ wheel/zoom event regardless of whether the threshold was actually crossed. It now +subscribes to `zoom >= LABEL_ZOOM_THRESHOLD` directly - a boolean that only changes value when the +threshold is crossed, so Zustand's default `Object.is` equality skips the re-render otherwise. This +was a prerequisite for adding the `holds`/`simulationSeeded` subscriptions the resolver needs, +not just a nicety on its own. + +## `localStorage` persistence limits + +[`seatStore.ts`](../src/store/seatStore.ts) persists `selectedSeats`, `soldSeats`, `bookings`, and +`selectionExpiresAt` to `localStorage` via a custom `PersistStorage` that serializes `Set` +to a plain array. This is enough for the current scope (survive a page refresh, single device, no +login) but has real limits: + +- `localStorage` has a per-origin size ceiling (typically ~5–10MB depending on browser) - irrelevant + at 8 selected seats, but the pattern shouldn't be reused to persist large datasets. +- No cross-device sync - a selection doesn't follow a user to a different browser or device, and + there's no server-side inventory, so two different people (or the same person on two devices) + could still both believe they've bought the same seat. +- **Cross-_tab_ contention on one browser is now handled** (not cross-device) - see + [`holdProtocol.ts`](../src/utils/holdProtocol.ts) and the "Live seat-hold contention" section + below. `holds` and `selectionExpiresAt`'s live countdown are deliberately kept _out_ of + `partialize`/only partially persisted: holds are time-based shared state that would fight + `BroadcastChannel` sync if `localStorage` were also treated as a source of truth for them. +- A real backend would still replace all of this with a server-held reservation (a TTL/hold expiry + enforced by a server nothing stops a client from lying about) and use `localStorage` only as an + optimistic local echo, not the source of truth. + +## Live seat-hold contention: the performance guarantee, and its real limits + +[`useHoldExpiry.ts`](../src/hooks/useHoldExpiry.ts) arms exactly one `setTimeout` for whichever +comes soonest - a tracked hold lapsing or this tab's own checkout deadline - rather than polling. +On fire, it calls `runExpiry()` in [`seatStore.ts`](../src/store/seatStore.ts), which prunes via +[`holdProtocol.ts`](../src/utils/holdProtocol.ts)'s `pruneHolds()`. That function returns the +**same** `Map` reference when nothing has actually lapsed, and the store skips its `set()` call +entirely in that case - so a routine tick with nothing to do costs zero re-renders across the +~1,500 mounted seats, not "one small one." This is asserted with a reference-equality (`toBe`) test +in `seatStore.test.ts`, specifically so a future refactor that starts always returning a new `Map` +fails a test instead of silently reintroducing a per-tick re-render. + +**What this simulation is honest about not being:** [`holdChannel.ts`](../src/utils/holdChannel.ts)'s +`BroadcastChannel` transport only reaches other tabs of the _same browser on the same machine_ - +there is no server, so two different visitors on two different computers still can't contend for +the same seat here. The tie-break for a genuine same-instant conflict (lower session id wins, +in [`holdProtocol.ts`](../src/utils/holdProtocol.ts)'s `ingestPeerHold`) is a deterministic +convenience, not consensus - a real implementation would need a server to arbitrate. And the +"simulated" holds seeded onto venue.json's static `held` seats are exactly that: a demonstration +of the mechanism, not a claim about real inventory. + +## Known remaining gaps + +- No virtualization within an active section's own seat list (see above). +- The "Proceed to Pay" flow (see + [`SeatSelectionFooter.tsx`](../src/components/SeatSelectionFooter.tsx)) is an intentional UI + stub - it shows an inline confirmation, not a real payment integration. `confirmPurchase()` does + re-validate the checkout deadline before completing, so at least the "the hold ran out mid-pay" + failure mode is handled - see the "Live seat-hold contention" section above. +- ~~Seat availability (`sold`/`reserved`/`held`) is static sample data with no live update + mechanism (no websocket/polling) - a second user selecting the same seat isn't reflected without + a refresh.~~ **Partly resolved**: `held` seats now expire and release on a real timer, and a + second seat-selecting _tab of the same browser_ is reflected live via `BroadcastChannel` (see + above). The remaining, honest limitation: this is same-browser only. Two different visitors on + two different machines still can't see each other's live selections - that needs a server + pushing real inventory changes, which this project deliberately doesn't have. +- **Test coverage is well below the org engineering standard.** The standard + ([`Webvoltz-Engineering-Standards/react`](../../Webvoltz-Engineering-Standards/react)) requires + branches 85% / functions 100% / lines 90% / statements 90%. Actual project-wide coverage today + (`pnpm test`, V8 provider, whole `src/` tree): **statements 54.1%, branches 56.2%, functions + 49.4%, lines 55.1%** - up substantially since the best-seats solver and live-hold work added + ~15 new, near-100%-covered pure-logic modules under `src/utils/` and `src/hooks/`, but most + _components_ still have no tests: `App.tsx`, `VenueMap.tsx`, `Section.tsx`, `BookingSummary.tsx`, + `SeatSelectionFooter.tsx`, `BookingHistoryPanel.tsx`, `HoldRuntime.tsx`, `IconButton.tsx`, + `Toast.tsx`, and `useVenue.ts` have none. The thresholds in [`vite.config.ts`](../vite.config.ts) + are ratcheted up alongside real coverage gains (set just under today's actual numbers as a floor + against regression), not set to the standard's targets - reaching those targets means writing + real test suites for all of the above, which is a substantial task on its own, not a quick config + change. +- ~~CI's pnpm version pin doesn't match the actual pnpm in use~~ **Resolved**: standardized on + pnpm 11.x, matching this project's actual dev environment (the one pnpm version proven to work + here all session). `.github/workflows/ci.yml`'s `PNPM_VERSION` is now `'11'`, and + `package.json` pins `"packageManager": "pnpm@11.6.0"` to match - `pnpm install` confirmed clean + locally with both in place (this is exactly the combination that previously failed when CI said + `'10'` and `packageManager` said `pnpm@11.6.0`: pnpm tried to force-switch versions and aborted + without an interactive terminal). Not independently verified against a real GitHub Actions run + from here, but there's no reason pnpm 11 - a current, published major version - shouldn't install + and run cleanly in that environment the same way it has here. diff --git a/docs/screenshots/booking-history.png b/docs/screenshots/booking-history.png new file mode 100644 index 0000000..2878885 Binary files /dev/null and b/docs/screenshots/booking-history.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000..526392c Binary files /dev/null and b/docs/screenshots/overview.png differ diff --git a/docs/screenshots/seat-selection.png b/docs/screenshots/seat-selection.png new file mode 100644 index 0000000..1791320 Binary files /dev/null and b/docs/screenshots/seat-selection.png differ diff --git a/docs/ui-ux-and-business-logic-flow.md b/docs/ui-ux-and-business-logic-flow.md new file mode 100644 index 0000000..37277f3 --- /dev/null +++ b/docs/ui-ux-and-business-logic-flow.md @@ -0,0 +1,885 @@ +# UI/UX & Business Logic - Technical Flow + +This document walks through **exactly how the current codebase behaves**, end to end: every UI +state, every interaction, and every business rule, with the file/line it lives in. It complements +[`README.md`](../README.md) (project overview, setup) and +[`engineering-notes.md`](engineering-notes.md) (performance tradeoffs) - this file is the "what +happens when you click X" reference. + +--- + +## 1. Architecture at a glance + +```mermaid +flowchart TD + A["App.tsx
loading/error/data states, zoom, pan, mobile sidebar"] --> B["useVenue()
fetch /venue.json"] + A --> C[VenueMap.tsx] + A --> D[BookingSummary.tsx] + A --> E[Toast.tsx] + C --> F["Section.tsx Γ— 10
collapsed or active"] + F -->|active only| G["Seat.tsx (Seats)
~1,500 seat + label nodes"] + A --> H["seatStore (Zustand)
zoom Β· activeSectionId Β· selectedSeats Β· soldSeats Β· bookings Β· viewingBookingId Β· feedback"] + H -.persist.-> I[(localStorage: selectedSeats + soldSeats + bookings)] + D -.reads/writes.-> H + G -.reads/writes.-> H + F -.reads/writes.-> H + H -.feedback.-> E +``` + +Single-page app, no router. `App.tsx` owns page-level UI state (loading/error, mobile sidebar, +mouse-drag panning); `seatStore` (Zustand) owns cross-component venue state (zoom, which section is +open, which seats are selected/sold, transient toast feedback). + +--- + +## 2. Data model + +[`venue.interfaces.ts`](../src/interfaces/venue.interfaces.ts): + +```ts +SeatStatus = 'available' | 'reserved' | 'sold' | 'held' +ISeat = { id, col, x, y, priceTier, status } +Row = { index, seats: ISeat[] } +Section = { id, label, transform, rows: Row[] } +Venue = { venueId, name, map: { width, height }, sections: Section[] } +``` + +Generated once by [`scripts/generateVenue.ts`](../scripts/generateVenue.ts) into +`public/venue.json`: **10 sections Γ— 30 rows Γ— 50 seats = 15,000 seats**, arranged radially around +a 2400Γ—2400 coordinate space. Price tier by row index: rows 0–4 β†’ tier 1 (front, priciest), 5–14 β†’ +tier 2, 15–29 β†’ tier 3 (back, cheapest). `status` is seeded per seat (~70% `available`, ~15% +`sold`, ~10% `reserved`, ~5% `held` - see Β§14) rather than uniformly `available`. + +`x`/`y` per seat are pre-computed polar coordinates (already resolved to cartesian) - no +trigonometry happens at render time, only at generation time. + +--- + +## 3. State: `seatStore` ([`seatStore.ts`](../src/store/seatStore.ts)) + +| Field | Type | Persisted? | Set by | +| -------------------- | ------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `zoom` | `number` | ❌ | fit-on-load effect, zoom buttons, section open/close, resume-selection effect | +| `activeSectionId` | `string \| null` | ❌ | clicking/keying a section, clicking the empty map background, viewing a booking, `selectSeatBlock` | +| `selectedSeats` | `Set` | βœ… (custom serializer) | `toggleSeat`, `selectSeatBlock`, `clearSelection`, `confirmPurchase` (empties it), a lost hold tie-break | +| `selectionExpiresAt` | `number \| null` | βœ… (plain number) | `toggleSeat` (first seat starts it), `selectSeatBlock`, cleared by `clearSelection`/`confirmPurchase`/expiry | +| `soldSeats` | `Set` | βœ… (custom serializer) | `confirmPurchase` (adds the seats just paid for), a remote `sold` broadcast | +| `bookings` | `Booking[]` | βœ… (plain JSON) | `confirmPurchase` (prepends the new booking; newest first) | +| `viewingBookingId` | `string \| null` | ❌ | `viewBooking`/`clearViewingBooking`, cleared by `toggleSeat`/`selectSeatBlock` | +| `feedback` | `{type, message} \| null` | ❌ | `toggleSeat`/`selectSeatBlock` (max-seat cap), the best-seats finder, hold contention, cleared by `Toast` after 4s or dismiss | +| `holds` | `Map` | ❌ | `seedSimulation`, `setHolds`, `applyRemoteSold`, `runExpiry` (prunes lapsed entries) | +| `simulationSeeded` | `boolean` | ❌ | `seedSimulation` (guards against seeding twice - e.g. a late cross-tab reply) | + +**Persistence**: `partialize` scopes `localStorage` (`key: venue-storage`) to `selectedSeats`, +`soldSeats`, `bookings`, **and** `selectionExpiresAt`. Since `Set` isn't JSON-native, a custom +`PersistStorage` converts each `Set ↔ string[]` on `getItem`/`setItem` (`bookings` is a +plain array of plain objects, so it round-trips through `JSON.stringify`/`parse` with no custom +conversion needed) - `zoom`, `activeSectionId`, `viewingBookingId`, `feedback`, `holds`, and +`simulationSeeded` are intentionally transient and reset on reload. `holds` in particular is +deliberately excluded even though it's meaningful state: it's time-based (a stale hold from last +session means nothing) and shared cross-tab via `BroadcastChannel`, so treating `localStorage` as a +second source of truth for it would fight that sync instead of complementing it. See +[`docs/engineering-notes.md`](engineering-notes.md#localstorage-persistence-limits). + +**`toggleSeat(seatId)`** - the core selection rule: + +``` +if the seat has a live foreign hold β†’ set feedback (error toast), no-op +else if already selected β†’ remove it +else if selectedSeats.size >= MAX_SELECTABLE_SEATS (8) β†’ set feedback (error toast), no-op +else β†’ add it, and if this is the first seat, start selectionExpiresAt (now + 5 minutes) +``` + +Also always clears `viewingBookingId` - starting or editing a live selection supersedes browsing +past booking history, so the two sidebar modes (Β§8) never end up fighting over what the map should +show. Adding a second (or third, ...) seat does **not** push the deadline back - it's anchored to +when the cart was opened, matching how a real ticketing site's hold works. No server round-trip for +the sale itself, but see Β§15 for how holds now coordinate across tabs of the same browser. + +**`selectSeatBlock(seatIds, sectionId)`** - the best-seats finder's entry point (Β§16): replaces +`selectedSeats` wholesale rather than merging, sets `activeSectionId` to the winning section in the +same update (so there's no intermediate render where the seats are selected but not yet mounted), +clears `viewingBookingId`, and starts a fresh `selectionExpiresAt`. Refuses (with the same cap +feedback `toggleSeat` uses) if given zero seats or more than `MAX_SELECTABLE_SEATS`. + +**`confirmPurchase(total): Booking | null`** - the checkout transition (see Β§14 for the full +picture). Re-checks `selectionExpiresAt` against `Date.now()` **inside its own synchronous `set()` +call** before doing anything else: + +``` +if selectionExpiresAt is set and has already passed: + β†’ clear the cart, set an error feedback, return null (no booking recorded) +else: + β†’ move selectedSeats into soldSeats, record one Booking, clear the cart and the + deadline, "view" the new booking, return the Booking +``` + +Because Zustand's `set()` runs synchronously and JS is single-threaded, this check can't race with +the expiry timer (Β§15) - either the purchase's own `set()` call sees the deadline first, or the +expiry's `runExpiry()` does; there's no interleaving where both think they went first. Moves +whatever's currently selected into `soldSeats` (persisted, so it survives a reload) on success, +records it as one `Booking` grouping those seats together (so history can show/highlight them as a +unit rather than as loose entries in the flat `soldSeats` set), immediately "views" that new +booking, and empties the cart - as opposed to `clearSelection()`, which just abandons the cart with +no sale. `total` is passed in by the caller +([`BookingSummary.tsx`](../src/components/BookingSummary.tsx), via +[`SeatSelectionFooter.tsx`](../src/components/SeatSelectionFooter.tsx)'s Proceed to Pay button) +rather than computed here, since per-seat pricing lives in `venue` data the store doesn't hold. +`BookingSummary.tsx`'s `handleConfirmPurchase` only shows the "Booking confirmed" banner when the +return value is non-null, so a hold that expired mid-payment can't produce a false confirmation. + +**`viewBooking(id)` / `clearViewingBooking()`** - set/clear which past booking (if any) should +currently render its seats with the highlight ring described in Β§7; driven entirely by +[`BookingHistoryPanel.tsx`](../src/components/BookingHistoryPanel.tsx) (Β§8). + +--- + +## 4. Boot sequence & top-level UI states ([`App.tsx`](../src/App.tsx)) + +`useVenue()` ([`useVenue.ts`](../src/hooks/useVenue.ts)) fetches `/venue.json` once on mount and +exposes `{ venue, loading, error, refetch }`. `App` renders one of four exclusive states: + +1. **Loading** - centered spinner, `role="status" aria-live="polite"`, visible "Loading seating + map…" label. +2. **Error** - `role="alert"` card (icon, message, **Try again** button calling `refetch()`, which + re-runs the fetch effect via an internal retry-token state). +3. **No venue** (fetch succeeded but returned nothing) - plain empty-state card. +4. **Main app** - the seating map + booking sidebar (below). + +### Fit-to-viewport zoom (runs once venue loads) + +``` +availableWidth = mapContainer.clientWidth - 64 (fallback: window.innerWidth - sidebar - 64) +availableHeight = mapContainer.clientHeight - 64 (fallback: window.innerHeight - 64) +bounds = getVenueContentBounds(venue) // seats' real x/y extent + padding - see below +fitScale = min(availableWidth / bounds.width, availableHeight / bounds.height, 4) Γ— 0.96 +``` + +Uses the _real_ container size (via `mapContainerRef`), checking **both** dimensions - a +width-only calculation was an earlier bug that let the map render taller than the viewport. The +trailing `Γ— 0.96` (`FIT_SAFETY_MARGIN`) guarantees a small buffer so the fitted content never lands +exactly at the container's edge, which would otherwise risk forcing a scrollbar on its own - an +artifact of measuring `clientWidth`/`clientHeight` before vs. after a scrollbar appears. + +`computeFitZoom()` is shared between this effect and the **Reset view** button (Β§5), so the two +can never drift out of sync with each other. + +**Resume-selection branch**: if `selectedSeats` was restored from `localStorage` (non-empty), the +effect instead finds which section contains the first selected seat, calls +`setActiveSection(that section)` and `setZoom(2.5)`, and skips the fit-zoom entirely - so reloading +mid-selection drops you back into that section, already zoomed in. + +--- + +## 5. Zoom & pan + +| Control | Behavior | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **`+` / `βˆ’` buttons** | `zoom Β± 0.2`. Floor: `0.2` (`MIN_ZOOM`). **No ceiling** - zoom can increase indefinitely. Does _not_ try to keep any point centered; it's a plain `setZoom(...)` call. | +| **Reset view button** | Calls `setActiveSection(null)` (collapses whatever section is open) then re-runs the same `computeFitZoom()` the initial load uses - a one-click way back to the clean default view. | +| **Click-drag** | `onMouseDown` captures `{clientX + scrollLeft, clientY + scrollTop}` as an anchor; `onMouseMove` sets `scrollLeft/Top = anchor βˆ’ currentClientXY`. Cursor swaps `grab` ↔ `grabbing`. | +| **Native scroll / touch** | The map container is a plain `overflow-auto` element, so trackpad/touch scrolling works for free alongside the custom drag handler. | +| **Section click** | Sets `zoom = 5` (in [`Section.tsx`](../src/components/Section.tsx)) - high enough that seat numbers/row letters (see Β§7) are legible immediately, not just after manual zooming. | + +Every zoom change (buttons, reset, section open/close) animates smoothly rather than snapping: the +`` in `VenueMap.tsx` carries `style={{ transition: 'width 300ms ease-out, height 300ms +ease-out' }}`. Modern browsers treat an ``'s `width`/`height` as CSS-animatable presentation +properties, so this alone is enough - no JS-driven interpolation loop needed. Confirmed by sampling +the rendered width mid-transition in a real browser (e.g. partway between the pre- and post-zoom +values, not an instant jump). + +The map wrapper uses Tailwind's `items-center-safe`/`justify-center-safe` (flex "safe" alignment): +content is centered while it fits the viewport, but once it overflows, alignment falls back to +normal start-aligned/fully-scrollable behavior instead of making one side unreachable - the classic +flexbox-centering-plus-overflow trap. + +**A real, fixed bug worth knowing about**: the map `` must carry `shrink-0` - as a flex child +it would otherwise be silently compressed along the row axis (default `flex-shrink: 1`), squashing +the circle and capping horizontal scroll at the container's own width. See +[`VenueMap.tsx`](../src/components/VenueMap.tsx). + +**A second, subtler bug in the same area, since fixed**: fitting the zoom to the venue's _content_ +bounds (above) isn't enough on its own - `VenueMap.tsx`'s `` used to always render at the +_full_ `venue.map.width/height` (2400Γ—2400) canvas size scaled by zoom, regardless of what zoom was +chosen for. Since the content only occupies ~54% of that canvas, the rendered DOM element was still +oversized relative to the viewport in whatever dimension wasn't the binding constraint, which forced +a permanent scrollbar even though nothing was actually being clipped. The fix: +[`getVenueContentBounds()`](../src/utils/venueBounds.ts) is now also used inside `VenueMap.tsx` to +crop the SVG's own `viewBox`/`width`/`height` to the content region - so the rendered element's size +matches what's actually drawn, not the full padded canvas. Because that changes the SVG's own pixel +origin (the viewBox's top-left is now `bounds.minX`/`minY`, not `0, 0`), +[`Section.tsx`](../src/components/Section.tsx)'s scroll-to-center effect (Β§6) has to subtract that +offset before scaling by zoom - it now takes `bounds` as a prop for exactly that. + +--- + +## 6. Section interaction ([`Section.tsx`](../src/components/Section.tsx)) + +Each of the 10 sections is one ``, clickable **and** keyboard +operable (`Enter`/`Space`), with `aria-expanded` and a descriptive `aria-label`. + +- **Collapsed** (default): renders as a single traced `` "cover" shape (outline of its outer + row), a section-label ``, and a subtle hover/focus ring - one SVG node regardless of how + many seats the section holds. +- **Active** (clicked/activated): swaps to an invisible hit-target overlay `` (so clicking + inside the section's silhouette doesn't bubble up and deactivate it) plus ``, + which mounts every seat in that section individually. +- Clicking a section toggles it: opening one sets `zoom = 5`; clicking the already-open section (or + clicking empty map background, handled in `VenueMap.tsx`) closes it, leaving zoom as-is. +- On becoming active, a `useEffect` computes the section's bounding-box center and smooth-scrolls + the map container to it (150ms delay to let the zoom/DOM update land first). +- `Section` is `React.memo`-wrapped - panning, zooming, or selecting a seat inside the active + section never re-renders the other 9 (collapsed) sections. + +**Open/close fade animation**: the active and collapsed branches both render into a `` (`@keyframes section-activate` in `index.css`, a 250ms opacity +0β†’1 fade, skipped under `prefers-reduced-motion`), so swapping between them fades in rather than +snapping. **A real, fixed bug worth knowing about**: giving both branches the _same_ class name +isn't enough on its own - since both branches render the same element type (``) at the same +position in the tree, React reconciles them as an in-place _update_ of one persistent DOM node +rather than an unmount+remount. The `section-activate` class was present on that node continuously +across the toggle, so its computed `animation-name` never actually changed, and CSS never +restarts an animation that isn't changing - the fade silently never played, even though the class +was "correctly" applied in both branches. Fixed with `key="active"` / `key="collapsed"` on the two +``s, which forces React to treat them as distinct elements and genuinely remount on every +toggle, so the animation restarts every time. Confirmed in a real browser: sampling opacity ~30ms +and ~90ms after clicking a (previously-collapsed) section shows it ramping through intermediate +values (e.g. `0.40` β†’ `0.90`) before settling at `1`, rather than reading `1` throughout. + +--- + +## 7. Seat interaction ([`Seat.tsx`](../src/components/Seat.tsx)) + +Only rendered for the one active section (~1,500 `` nodes at once, never all +15,000). Each seat: + +- Square ``, `3Γ—3` units, `rx=0.35` (a clean rounded square, not the circular "dot" look an + earlier, proportionally-larger corner radius produced at small render sizes). +- Fill/stroke colors come from CSS custom properties defined once in + [`index.css`](../src/index.css)'s `@theme` block (`--color-seat-*`, `--color-tier-*`) - a single + source of truth shared with the legend swatches in `BookingSummary`, so they can't drift apart. + Looked up via [`seatStatus.ts`](../src/utils/seatStatus.ts)'s `SEAT_STATUS_STYLE` table, keyed by + effective status, rather than an if/else ladder recomputed per seat. +- **Seat number** (`seat.col`) and a **row letter** (`A, B, … Z, AA, AB, …`, spreadsheet-style, so + it never runs out) render as `` - but _only_ once `zoom >= 2`. Below that threshold the + ~1,500 extra label nodes simply aren't mounted at all, rather than rendering illegibly small text. + `Seats` subscribes to `zoom >= 2` as a **boolean**, not the raw `zoom` number - otherwise every + wheel/zoom event would re-render all ~1,500 mounted seats regardless of whether the threshold was + actually crossed. +- `role="checkbox"`, `aria-checked`, `aria-disabled` (sold/reserved/held), `aria-label` describing + row/seat/price/status, `tabIndex={0}` (or `-1` if unavailable) - real roving-focus keyboard + navigation, not a custom widget bolted onto a `
`. + +**Effective status** is resolved by [`resolveSeatStatus()`](../src/utils/seatStatus.ts) from the +seat's own `status`, `soldSeats`, the live `holds` map, `selectedSeats`, and whether the runtime +hold simulation has seeded yet - one function, one precedence order, replacing what used to be +several independent boolean checks. Precedence, highest first: **sold** (terminal) β†’ **held** (a +live, unexpired hold record - checked before "selected" as a fail-safe for the one frame after a +reload where a persisted selection might have since been claimed elsewhere) β†’ **selected** β†’ +**reserved** β†’ **available** (or **held**, if `seat.status` says so and the simulation hasn't +seeded yet - see Β§15). + +**Color states** (fill unless noted): + +| State | Fill | Stroke | +| --------- | ------------- | --------------------------------------------------------- | +| Available | white | tier color (border only) | +| Selected | emerald green | white ring (blue while it still has keyboard/click focus) | +| Sold | light gray | none | +| Reserved | amber | none | +| Held | red | none | + +**Click / Enter / Space** on an available seat β†’ `toggleSeat(seat.id)` (store) then `.focus()` the +element - so keyboard users land exactly where they just acted. On an **unavailable** seat, clicking +now sets an error feedback ("That seat is no longer available.") instead of silently doing nothing - +necessary because with live holds, a seat can genuinely go from available to held between paint and +click, and a silent no-op in that case reads as a bug. + +**Arrow-key navigation** (`handleKeyDown`): + +- `ArrowLeft`/`ArrowRight` β†’ focus `seatIndex Β± 1` within the same row. +- `ArrowUp`/`ArrowDown` β†’ focus `rowIndex Β± 1`, choosing whichever seat in that row has the + **closest `x` coordinate** (rows curve, so "same column" isn't a fixed index) - a linear scan + over that row's seats. + +A seat counts as unavailable (`sold`/`reserved`/`held`) if its resolved effective status isn't +`available` or `selected` - either way it's `tabIndex={-1}` and ignored by both click and keyboard +handlers (`isUnavailable` guard at the top of `handleInteraction`). A seat whose hold **lapses** +while it has keyboard focus stays focused and simply becomes interactive again - React patches the +same `` node's attributes in place rather than remounting it. + +**Booking-history highlight**: independent of the table above, a seat whose ID is in the currently +`viewingBookingId` booking's `seatIds` (see Β§8) additionally gets a violet ring +(`--color-booking-highlight`) plus a continuously-looping `seat-highlight-pulse` glow +(`filter: drop-shadow(...)`, `prefers-reduced-motion`-gated) - layered on top of, not replacing, +its normal status color (almost always "sold", since bookings are only ever created from a +completed purchase). It's deliberately a looping indicator rather than a one-shot flash: it's +meant to keep marking "these are the seats from the booking you're looking at" for as long as that +booking stays open in history, not to celebrate a single moment. + +--- + +## 8. Booking sidebar & checkout ([`BookingSummary.tsx`](../src/components/BookingSummary.tsx)) + +`BookingSummary` is a thin container with three stacked regions: a fixed header, a **scrollable** +middle region, and (only in selection mode) a **pinned, non-scrolling footer**: + +``` +
+ + +``` + +``` +showSelectionPanel = selectedSeats.size > 0 || confirmedTotal !== null +``` + +- **[`SeatSelectionPanel.tsx`](../src/components/SeatSelectionPanel.tsx)** - the scrollable + selected-seats list, shown the moment the user selects a seat. +- **[`SeatSelectionFooter.tsx`](../src/components/SeatSelectionFooter.tsx)** - Total Amount + + Proceed to Pay, rendered as `BookingSummary`'s own sibling **outside** the scrollable region. + **A real, fixed bug worth knowing about**: this used to live inside the same + `overflow-y-auto` region as the seat list (as part of `SeatSelectionPanel`) - with 8 seats + selected (the max), Proceed to Pay could end up scrolled below the fold, technically reachable + but not actually visible without scrolling past every seat row first, which fails the plain + expectation that a primary checkout action stays put. Splitting it into its own component and + rendering it as a pinned footer (not inside the `overflow-y-auto` div) fixes this regardless of + how many seats are selected or how tall the venue's own legend/list content gets. `total` is + computed once in `BookingSummary` (via the same shared `buildSeatIndex(venue)`) and passed down, + since the footer needs it but lives outside the component that used to own that calculation. +- **[`BookingHistoryPanel.tsx`](../src/components/BookingHistoryPanel.tsx)** - the default view, + shown whenever nothing is currently selected and no just-completed purchase is still on screen. + +The switch is automatic and has no separate "mode" state of its own - it's a pure function of +`selectedSeats.size` and the local `confirmedTotal` (see below), so selecting a seat, clearing the +cart, or finishing a purchase all naturally land on the right panel with no explicit transition +code. + +- **Price Tiers / Seat Status legend** - static reference using the same shared color tokens as the + map. Tier names/prices come from a shared `TIERS` map in + [`utils/seatIndex.ts`](../src/utils/seatIndex.ts): `1 β†’ Premium Stand ($7000)`, + `2 β†’ Standard Stand ($3000)`, `3 β†’ Gallery Stand ($1500)` (fallback `Standard Ticket`/`$50` for + an unrecognized tier number) - the same module both panels below use, so seat pricing/labels + can't drift between them. + +### `BestSeatsFinder.tsx` (Β§16) + +Rendered first in the scrollable region, **unconditionally** - it's visible in both sidebar modes +(unlike everything below it), since it's the sidebar's primary entry action rather than something +that depends on there already being a selection. A party-size `.'); + } + return el; +} + +function getRadio(value: string): HTMLInputElement { + const el = document.querySelector(`input[name="best-seats-priority"][value="${value}"]`); + if (!(el instanceof HTMLInputElement)) { + throw new Error(`Expected a priority radio with value "${value}".`); + } + return el; +} + +describe('BestSeatsFinder', () => { + beforeEach(resetStore); + + it('renders a labelled party-size select and a view/price radio group, with view checked by default', () => { + render(); + expect(getPartySizeSelect().value).toBe('2'); + expect(getRadio('view').checked).toBe(true); + expect(getRadio('price').checked).toBe(false); + }); + + it('clicking the price radio checks it and unchecks view', () => { + render(); + fireEvent.click(getRadio('price')); + expect(getRadio('price').checked).toBe(true); + expect(getRadio('view').checked).toBe(false); + }); + + it('submitting a satisfiable party size selects exactly the found block and focuses its section', () => { + render(); + fireEvent.change(getPartySizeSelect(), { target: { value: '3' } }); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + const { selectedSeats, activeSectionId } = useVenueStore.getState(); + expect(selectedSeats.size).toBe(3); + expect(activeSectionId).toBe('S-1'); + }); + + it('"Best price" finds a cheaper block than "Best view" on the same venue', () => { + const { unmount } = render(); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + const viewSelection = new Set(useVenueStore.getState().selectedSeats); + unmount(); + + resetStore(); + render(); + fireEvent.click(getRadio('price')); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + const priceSelection = new Set(useVenueStore.getState().selectedSeats); + + expect(priceSelection).not.toEqual(viewSelection); + }); + + it('submitting when nothing fits sets an error and leaves the selection empty', () => { + render(); + fireEvent.change(getPartySizeSelect(), { target: { value: '8' } }); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + expect(useVenueStore.getState().feedback?.type).toBe('error'); + }); + + it('excludes seats already sold in this browser', () => { + useVenueStore.setState({ + soldSeats: new Set([1, 2, 3, 4, 5].map((c) => `S-1-${c}`)), + }); + render(); + fireEvent.change(getPartySizeSelect(), { target: { value: '3' } }); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + const seatIds = Array.from(useVenueStore.getState().selectedSeats); + expect(seatIds.length).toBe(3); + expect(seatIds.every((id) => id.startsWith('S-2-'))).toBe(true); + }); + + it('with an existing selection, submitting asks for confirmation before replacing it', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-existing']) }); + render(); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + expect(useVenueStore.getState().selectedSeats.has('SEAT-existing')).toBe(true); + expect(document.body.textContent).toContain('Replace your current selection?'); + }); + + it('cancelling the replace confirmation leaves the original selection untouched', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-existing']) }); + render(); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + const cancelButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'Cancel', + ); + if (!cancelButton) throw new Error('Expected a Cancel button in the confirm dialog.'); + fireEvent.click(cancelButton); + + expect(Array.from(useVenueStore.getState().selectedSeats)).toEqual(['SEAT-existing']); + }); + + it('confirming the replace applies the newly found block', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-existing']) }); + render(); + fireEvent.click(document.querySelector('button[type="submit"]') ?? document.body); + + const confirmButton = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'Replace', + ); + if (!confirmButton) throw new Error('Expected a Replace button in the confirm dialog.'); + fireEvent.click(confirmButton); + + const { selectedSeats } = useVenueStore.getState(); + expect(selectedSeats.has('SEAT-existing')).toBe(false); + expect(selectedSeats.size).toBe(2); + }); +}); diff --git a/src/components/BestSeatsFinder.tsx b/src/components/BestSeatsFinder.tsx new file mode 100644 index 0000000..a53d3c5 --- /dev/null +++ b/src/components/BestSeatsFinder.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { MAX_SELECTABLE_SEATS, useVenueStore } from '../store/seatStore'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { findBestSeats } from '../utils/findBestSeats'; +import type { SeatBlock, SeatPriority } from '../utils/findBestSeats'; +import Icon from './Icon'; +import ConfirmDialog from './ConfirmDialog'; + +interface BestSeatsFinderProps { + venue: Venue; +} + +const PRIORITY_LABELS: Record = { + view: 'Best view', + price: 'Best price', +}; + +const PARTY_SIZE_OPTIONS = Array.from({ length: MAX_SELECTABLE_SEATS }, (_, i) => i + 1); + +function describeBlock(block: SeatBlock): string { + const seatCount = block.seatIds.length; + const seatWord = seatCount === 1 ? 'seat' : 'seats'; + const seatRange = + block.startCol === block.endCol + ? `Seat ${block.startCol}` + : `Seats ${block.startCol}-${block.endCol}`; + return `Found ${seatCount} ${seatWord} - ${block.sectionLabel} Β· Row ${block.rowLabel} Β· ${seatRange} Β· $${block.totalPrice}`; +} + +export default function BestSeatsFinder({ venue }: BestSeatsFinderProps) { + const selectedSeatsCount = useVenueStore((s) => s.selectedSeats.size); + const selectSeatBlock = useVenueStore((s) => s.selectSeatBlock); + const setFeedback = useVenueStore((s) => s.setFeedback); + const [partySize, setPartySize] = useState(2); + const [priority, setPriority] = useState('view'); + const [showReplaceConfirm, setShowReplaceConfirm] = useState(false); + + const runSearch = () => { + const { soldSeats } = useVenueStore.getState(); + const block = findBestSeats( + venue, + partySize, + priority, + (seat) => seat.status === 'available' && !soldSeats.has(seat.id), + ); + + if (!block) { + setFeedback({ + type: 'error', + message: `No block of ${partySize} adjacent seats is available. Try a smaller party size.`, + }); + return; + } + + selectSeatBlock(block.seatIds, block.sectionId); + setFeedback({ type: 'info', message: describeBlock(block) }); + }; + + const handleSubmit = (e: React.SubmitEvent) => { + e.preventDefault(); + if (selectedSeatsCount > 0) { + setShowReplaceConfirm(true); + return; + } + runSearch(); + }; + + return ( +
+

+ Find Best Seats +

+ +
+
+ + +
+ +
+ Priority +
+ {(['view', 'price'] as const).map((option) => ( + + ))} +
+
+ + +

+ Replaces your current selection. +

+
+ + {showReplaceConfirm && ( + { + setShowReplaceConfirm(false); + runSearch(); + }} + onCancel={() => { + setShowReplaceConfirm(false); + }} + /> + )} +
+ ); +} diff --git a/src/components/BookingHistoryPanel.tsx b/src/components/BookingHistoryPanel.tsx new file mode 100644 index 0000000..3be9656 --- /dev/null +++ b/src/components/BookingHistoryPanel.tsx @@ -0,0 +1,129 @@ +import { useMemo } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import type { Booking } from '../store/seatStore'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { buildSeatIndex } from '../utils/seatIndex'; + +interface BookingHistoryPanelProps { + venue: Venue; +} + +// Five roughly-88px cards plus gaps, then scroll - a fixed cap rather than a +// dynamic count so the sidebar's own height never changes as history grows. +const VISIBLE_BOOKINGS = 5; +const CARD_HEIGHT_PX = 88; +const GAP_PX = 12; +const LIST_MAX_HEIGHT = VISIBLE_BOOKINGS * CARD_HEIGHT_PX + (VISIBLE_BOOKINGS - 1) * GAP_PX; + +export default function BookingHistoryPanel({ venue }: BookingHistoryPanelProps) { + const bookings = useVenueStore((s) => s.bookings); + const viewingBookingId = useVenueStore((s) => s.viewingBookingId); + const viewBooking = useVenueStore((s) => s.viewBooking); + const clearViewingBooking = useVenueStore((s) => s.clearViewingBooking); + const setActiveSection = useVenueStore((s) => s.setActiveSection); + + const seatIndex = useMemo(() => buildSeatIndex(venue), [venue]); + + const handleBookingClick = (booking: Booking) => { + if (viewingBookingId === booking.id) { + // Clicking the already-active booking again closes it out, matching + // the same open/close toggle a section itself uses. + clearViewingBooking(); + setActiveSection(null); + return; + } + + const firstSeatId = booking.seatIds[0]; + const firstEntry = firstSeatId ? seatIndex.get(firstSeatId) : undefined; + if (firstEntry) { + setActiveSection(firstEntry.sectionId); + } + viewBooking(booking.id); + }; + + return ( +
+

+ Booking History +

+ + {bookings.length === 0 ? ( +
+ +

No bookings yet

+
+ ) : ( +
+ {bookings.map((booking) => { + const sectionLabels = Array.from( + new Set( + booking.seatIds + .map((id) => seatIndex.get(id)?.sectionLabel) + .filter((label): label is string => Boolean(label)), + ), + ); + // Destructuring (rather than checking `sectionLabels.length`) is + // what lets TypeScript actually narrow `firstLabel` to `string` + // below - indexed access alone stays `string | undefined` under + // noUncheckedIndexedAccess even inside a length check. + const [firstLabel, ...restLabels] = sectionLabels; + const primaryLabel = firstLabel + ? restLabels.length > 0 + ? `${firstLabel} +${restLabels.length} more` + : firstLabel + : 'Unknown section'; + const isActive = viewingBookingId === booking.id; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/BookingSummary.tsx b/src/components/BookingSummary.tsx index 0cd1d1f..58aa685 100644 --- a/src/components/BookingSummary.tsx +++ b/src/components/BookingSummary.tsx @@ -1,148 +1,101 @@ -import { useVenueStore } from "../store/seatStore"; -import type { Venue } from "../interfaces/venue.interfaces"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { buildSeatIndex, getTier } from '../utils/seatIndex'; +import SeatSelectionPanel from './SeatSelectionPanel'; +import SeatSelectionFooter from './SeatSelectionFooter'; +import BookingHistoryPanel from './BookingHistoryPanel'; +import BestSeatsFinder from './BestSeatsFinder'; interface BookingSummaryProps { - venue: Venue; + venue: Venue; } -export default function BookingSummary({ venue }: BookingSummaryProps) { - const selectedSeats = useVenueStore((s) => s.selectedSeats); - const clearSelection = useVenueStore((s) => s.clearSelection); - - - // Get selected seat details - const selectedSeatDetails = useMemo(() => { - const details: { id: string; label: string; price: number; tier: number }[] = []; - if (selectedSeats.size === 0) return details; +const CONFIRMATION_DISPLAY_MS = 3000; - const tierPrices: Record = { - 1: 7000, - 2: 3000, - 3: 1500, - }; - - venue.sections.forEach(section => { - section.rows.forEach(row => { - row.seats.forEach(seat => { - if (selectedSeats.has(seat.id)) { - details.push({ - id: seat.id, - label: `${section.label} | Row ${row.index} | Seat ${seat.col}`, - price: tierPrices[seat.priceTier] || 50, - tier: seat.priceTier - }); - } - }); - }); - }); - return details; - }, [venue, selectedSeats]); +export default function BookingSummary({ venue }: BookingSummaryProps) { + const selectedSeats = useVenueStore((s) => s.selectedSeats); + const confirmPurchase = useVenueStore((s) => s.confirmPurchase); + const [confirmedTotal, setConfirmedTotal] = useState(null); - const total = selectedSeatDetails.reduce((sum, item) => sum + item.price, 0); + useEffect(() => { + if (confirmedTotal === null) return; + const timer = setTimeout(() => { + setConfirmedTotal(null); + }, CONFIRMATION_DISPLAY_MS); + return () => { + clearTimeout(timer); + }; + }, [confirmedTotal]); - return ( -
-
-

- {venue?.name} -

-
+ const handleConfirmPurchase = (total: number) => { + const booking = confirmPurchase(total); + if (booking) setConfirmedTotal(total); + }; -
+ const showSelectionPanel = selectedSeats.size > 0 || confirmedTotal !== null; -
-

Price Tiers

-
-
-
-
- Tier 1 -
- 7000$ -
-
-
-
- Tier 2 -
- 3000$ -
-
-
-
- Tier 3 -
- 1500$ -
-
+ const seatIndex = useMemo(() => buildSeatIndex(venue), [venue]); + const total = useMemo(() => { + let sum = 0; + selectedSeats.forEach((seatId) => { + sum += seatIndex.get(seatId)?.price ?? 0; + }); + return sum; + }, [seatIndex, selectedSeats]); -

Seat Status

-
-
-
- Selected -
-
-
- Sold -
-
-
- Reserved -
-
-
- Held -
-
-
+ return ( +
+
+

{venue.name}

+
-
-

- Selected Seats ({selectedSeats.size}/8) - {selectedSeats.size > 0 && ( - - )} -

+
+ - {selectedSeats.size === 0 ? ( -
-

Select seats from the map

-
- ) : ( -
- {selectedSeatDetails.map((item) => ( -
-
-

{item.label}

-

Standard Ticket

-
- {item.price} -
- ))} -
- )} -
- -
-
- Total Amount - {total}$ -
- -
+
+

+ Price Tiers +

+
+
+
+
+ {getTier(1).name} +
+ ${getTier(1).price} +
+
+
+
+ {getTier(2).name} +
+ ${getTier(2).price} +
+
+
+
+ {getTier(3).name} +
+ ${getTier(3).price}
+
- ); + + {showSelectionPanel ? ( + + ) : ( + + )} +
+ + {showSelectionPanel && ( + + )} +
+ ); } diff --git a/src/components/CheckoutTimer.test.tsx b/src/components/CheckoutTimer.test.tsx new file mode 100644 index 0000000..79c58a2 --- /dev/null +++ b/src/components/CheckoutTimer.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { useVenueStore } from '../store/seatStore'; +import CheckoutTimer from './CheckoutTimer'; + +const resetStore = () => { + useVenueStore.setState({ selectionExpiresAt: null }); +}; + +function getTimer(): HTMLElement { + const el = document.querySelector('[role="timer"]'); + if (!(el instanceof HTMLElement)) throw new Error('Expected a [role="timer"] element to render.'); + return el; +} + +describe('CheckoutTimer', () => { + beforeEach(() => { + resetStore(); + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing when there is no checkout deadline', () => { + render(); + expect(document.querySelector('[role="timer"]')).toBeNull(); + }); + + it('shows the initial time remaining and ticks down after a second', () => { + useVenueStore.setState({ selectionExpiresAt: 300_000 }); + render(); + + const digits = getTimer().querySelector('span[aria-hidden="true"]'); + expect(digits?.textContent).toBe('5:00'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(getTimer().querySelector('span[aria-hidden="true"]')?.textContent).toBe('4:59'); + }); + + it('the ticking digits are hidden from assistive tech; the container carries the accessible name', () => { + useVenueStore.setState({ selectionExpiresAt: 300_000 }); + render(); + + const timer = getTimer(); + expect(timer.getAttribute('aria-label')).toBe('Time left to complete your booking'); + const digits = timer.querySelector('span[aria-hidden="true"]'); + expect(digits).not.toBeNull(); + expect(digits?.textContent).toBe('5:00'); + }); + + it('the live-region announcement is stable across ticks within one band', () => { + useVenueStore.setState({ selectionExpiresAt: 119_000 }); + render(); + const before = getTimer().querySelector('[aria-live="polite"]')?.textContent; + + act(() => { + vi.advanceTimersByTime(30_000); + }); + const after = getTimer().querySelector('[aria-live="polite"]')?.textContent; + + expect(after).toBe(before); + }); + + it('the live-region announcement changes on crossing the 2-minute boundary', () => { + useVenueStore.setState({ selectionExpiresAt: 121_000 }); + render(); + const before = getTimer().querySelector('[aria-live="polite"]')?.textContent; + + act(() => { + vi.advanceTimersByTime(2000); + }); + const after = getTimer().querySelector('[aria-live="polite"]')?.textContent; + + expect(after).not.toBe(before); + }); + + it('applies warning styling once under a minute remains', () => { + useVenueStore.setState({ selectionExpiresAt: 45_000 }); + render(); + expect(getTimer().className).toContain('border-amber-300'); + }); +}); diff --git a/src/components/CheckoutTimer.tsx b/src/components/CheckoutTimer.tsx new file mode 100644 index 0000000..ebd4b46 --- /dev/null +++ b/src/components/CheckoutTimer.tsx @@ -0,0 +1,56 @@ +import { useVenueStore } from '../store/seatStore'; +import { useCountdown } from '../hooks/useCountdown'; +import { SELECTION_HOLD_MS } from '../utils/holdProtocol'; +import { + describeCountdownMilestone, + formatCountdown, + pickCountdownMilestone, +} from '../utils/formatCountdown'; +import Icon from './Icon'; + +const URGENT_THRESHOLD_MS = 60_000; +const PULSE_THRESHOLD_MS = 30_000; + +export default function CheckoutTimer() { + const selectionExpiresAt = useVenueStore((s) => s.selectionExpiresAt); + const remaining = useCountdown(selectionExpiresAt); + + if (remaining === null) return null; + + const isUrgent = remaining <= URGENT_THRESHOLD_MS; + const progressPercent = Math.min(100, Math.max(0, (remaining / SELECTION_HOLD_MS) * 100)); + + return ( +
+
+ + + Seats held for + + +
+
+
+
+ + {describeCountdownMilestone(pickCountdownMilestone(remaining))} + +
+ ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7c02c60 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +interface ConfirmDialogProps { + title: string; + message: string; + confirmLabel: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ConfirmDialog({ + title, + message, + confirmLabel, + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onCancel]); + + // The backdrop button already blocks mouse clicks on the app behind it, + // but Tab navigation could still reach it (a real gap, not just a visual + // one - screen-reader/keyboard users could otherwise select or clear + // seats while this dialog is "modally" open). `inert` removes the app + // root from both focus and hit-testing entirely, for as long as the + // dialog is mounted. + useEffect(() => { + const appRoot = document.getElementById('root'); + appRoot?.setAttribute('inert', ''); + return () => { + appRoot?.removeAttribute('inert'); + }; + }, []); + + // Rendered via a portal straight into rather than in place: the + // sidebar wrapper in App.tsx carries an active translate-x-* transform + // (for its mobile slide-in/out), and a `transform` on any ancestor creates + // a new containing block for `position: fixed` descendants - so without + // the portal, "fixed inset-0" here would size itself to that sidebar box + // instead of the actual viewport, confining the dialog to the left column. + return createPortal( +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/HoldRuntime.tsx b/src/components/HoldRuntime.tsx new file mode 100644 index 0000000..67727b7 --- /dev/null +++ b/src/components/HoldRuntime.tsx @@ -0,0 +1,13 @@ +import type { Venue } from '../interfaces/venue.interfaces'; +import { useHoldExpiry } from '../hooks/useHoldExpiry'; +import { useHoldSync } from '../hooks/useHoldSync'; + +interface HoldRuntimeProps { + venue: Venue; +} + +export default function HoldRuntime({ venue }: HoldRuntimeProps) { + useHoldExpiry(); + useHoldSync(venue); + return null; +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..4c2791c --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,37 @@ +const PATHS = { + menu: 'M4 6h16M4 12h16M4 18h16', + close: 'M6 18L18 6M6 6l12 12', + 'zoom-in': 'M12 5v14M5 12h14', + 'zoom-out': 'M5 12h14', + // Scaled 24/21 from a reference "zoom reset" icon's original 21x21 viewBox. + 'reset-view': + 'M4 9.71C4 12.87 6.56 15.43 9.71 15.43C12.87 15.43 15.43 12.87 15.43 9.71C15.43 6.56 12.87 4 9.71 4C7.88 4 6.25 4.86 5.21 6.2M5.14 1.71L5.14 6.29L9.71 6.29M20 20L13.85 13.85', + 'chevron-left': 'M15 6l-6 6 6 6', + search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM21 21l-4.35-4.35', + clock: 'M12 8v4l3 3M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z', +} as const; + +export type IconName = keyof typeof PATHS; + +interface IconProps { + name: IconName; + className?: string; +} + +export default function Icon({ name, className = 'w-5 h-5' }: IconProps) { + return ( + + ); +} diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx new file mode 100644 index 0000000..d6a643b --- /dev/null +++ b/src/components/IconButton.tsx @@ -0,0 +1,32 @@ +import Icon, { type IconName } from './Icon'; + +interface IconButtonProps { + icon: IconName; + label: string; + onClick: () => void; + className?: string; + /** Extra classes applied to the icon itself (e.g. a rotation transition), + * on top of its default size - for buttons whose glyph animates + * independently of the button's own position/visibility. */ + iconClassName?: string; +} + +export default function IconButton({ + icon, + label, + onClick, + className = '', + iconClassName = '', +}: IconButtonProps) { + return ( + + ); +} diff --git a/src/components/Seat.test.tsx b/src/components/Seat.test.tsx new file mode 100644 index 0000000..c4f5996 --- /dev/null +++ b/src/components/Seat.test.tsx @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import type { HoldMap, Row } from '../interfaces/venue.interfaces'; +import { useVenueStore } from '../store/seatStore'; +import Seats from './Seat'; + +function holdMap(...holds: { seatId: string; owner: string; expiresAt: number }[]): HoldMap { + return new Map(holds.map((h) => [h.seatId, h])); +} + +const rows: Row[] = [ + { + index: 1, + seats: [ + { id: 'A1', col: 1, x: 0, y: 0, priceTier: 1, status: 'available' }, + { id: 'A2', col: 2, x: 10, y: 0, priceTier: 1, status: 'available' }, + { id: 'A3', col: 3, x: 20, y: 0, priceTier: 1, status: 'available' }, + ], + }, + { + index: 2, + seats: [ + { id: 'B1', col: 1, x: 0, y: 10, priceTier: 1, status: 'available' }, + { id: 'B2', col: 2, x: 10, y: 10, priceTier: 1, status: 'available' }, + { id: 'B3', col: 3, x: 20, y: 10, priceTier: 1, status: 'sold' }, + ], + }, +]; + +const renderSeats = () => render({}); + +function getSeat(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`Expected element #${id} to exist in the rendered test DOM.`); + return el; +} + +function requireElement(el: Element | null): Element { + if (!el) throw new Error('Expected an element (e.g. document.activeElement) but got null.'); + return el; +} + +const resetStore = () => { + useVenueStore.setState({ + activeSectionId: null, + selectedSeats: new Set(), + soldSeats: new Set(), + zoom: 0.4, + feedback: null, + holds: new Map(), + simulationSeeded: false, + }); +}; + +describe('Seats keyboard navigation and selection', () => { + beforeEach(resetStore); + + it('moves focus right and left within a row on ArrowRight/ArrowLeft', () => { + renderSeats(); + + const seatA2 = getSeat('seat-A2'); + seatA2.focus(); + fireEvent.keyDown(seatA2, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(document.getElementById('seat-A3')); + + fireEvent.keyDown(requireElement(document.activeElement), { key: 'ArrowLeft' }); + expect(document.activeElement).toBe(document.getElementById('seat-A2')); + }); + + it('moves focus to the nearest seat in the row above/below on ArrowUp/ArrowDown', () => { + renderSeats(); + + const seatA2 = getSeat('seat-A2'); + seatA2.focus(); + + fireEvent.keyDown(seatA2, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(document.getElementById('seat-B2')); + + fireEvent.keyDown(requireElement(document.activeElement), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(document.getElementById('seat-A2')); + }); + + it('toggles selection with Enter and Space', () => { + renderSeats(); + const seatA1 = getSeat('seat-A1'); + seatA1.focus(); + + fireEvent.keyDown(seatA1, { key: 'Enter' }); + expect(useVenueStore.getState().selectedSeats.has('A1')).toBe(true); + expect(seatA1.getAttribute('aria-checked')).toBe('true'); + + fireEvent.keyDown(seatA1, { key: ' ' }); + expect(useVenueStore.getState().selectedSeats.has('A1')).toBe(false); + }); + + it('toggles selection on click', () => { + renderSeats(); + const seatA2 = getSeat('seat-A2'); + + fireEvent.click(seatA2); + expect(useVenueStore.getState().selectedSeats.has('A2')).toBe(true); + }); + + it('ignores interaction on unavailable (sold) seats', () => { + renderSeats(); + const seatB3 = getSeat('seat-B3'); + + fireEvent.click(seatB3); + expect(useVenueStore.getState().selectedSeats.has('B3')).toBe(false); + expect(seatB3.getAttribute('tabindex')).toBe('-1'); + }); + + it('treats a seat in soldSeats as unavailable even if its data status is available', () => { + useVenueStore.setState({ soldSeats: new Set(['A2']) }); + renderSeats(); + const seatA2 = getSeat('seat-A2'); + + fireEvent.click(seatA2); + expect(useVenueStore.getState().selectedSeats.has('A2')).toBe(false); + expect(seatA2.getAttribute('tabindex')).toBe('-1'); + expect(seatA2.getAttribute('aria-disabled')).toBe('true'); + }); + + it('a live foreign hold renders as unavailable, and clicking it sets feedback instead of selecting', () => { + useVenueStore.setState({ + holds: holdMap({ seatId: 'A2', owner: 'peer', expiresAt: Date.now() + 60_000 }), + }); + renderSeats(); + const seatA2 = getSeat('seat-A2'); + + expect(seatA2.getAttribute('aria-disabled')).toBe('true'); + expect(seatA2.getAttribute('tabindex')).toBe('-1'); + expect(seatA2.getAttribute('aria-label')).toContain('temporarily held by another customer'); + + fireEvent.click(seatA2); + expect(useVenueStore.getState().selectedSeats.has('A2')).toBe(false); + expect(useVenueStore.getState().feedback?.type).toBe('error'); + }); + + it('becomes selectable again once its hold has lapsed, keeping focus on the same element', () => { + useVenueStore.setState({ + holds: holdMap({ seatId: 'A2', owner: 'peer', expiresAt: Date.now() - 1 }), + }); + renderSeats(); + const seatA2 = getSeat('seat-A2'); + seatA2.focus(); + + expect(seatA2.getAttribute('aria-disabled')).toBe('false'); + fireEvent.click(seatA2); + expect(useVenueStore.getState().selectedSeats.has('A2')).toBe(true); + expect(document.activeElement).toBe(document.getElementById('seat-A2')); + }); + + it('a JSON-held seat reads as held before the simulation has seeded', () => { + const heldRows: Row[] = [ + { index: 1, seats: [{ id: 'H1', col: 1, x: 0, y: 0, priceTier: 1, status: 'held' }] }, + ]; + render({}); + const seat = getSeat('seat-H1'); + expect(seat.getAttribute('aria-disabled')).toBe('true'); + expect(seat.getAttribute('aria-label')).toContain('temporarily held by another customer'); + }); + + it('the same JSON-held seat reads as available once seeded, with no live hold record', () => { + useVenueStore.setState({ simulationSeeded: true }); + const heldRows: Row[] = [ + { index: 1, seats: [{ id: 'H1', col: 1, x: 0, y: 0, priceTier: 1, status: 'held' }] }, + ]; + render({}); + const seat = getSeat('seat-H1'); + expect(seat.getAttribute('aria-disabled')).toBe('false'); + }); +}); diff --git a/src/components/Seat.tsx b/src/components/Seat.tsx index 406d136..09e918a 100644 --- a/src/components/Seat.tsx +++ b/src/components/Seat.tsx @@ -1,142 +1,227 @@ -import { memo } from "react"; -import type { ISeat, Row } from "../interfaces/venue.interfaces"; -import { useVenueStore } from "../store/seatStore"; +import { memo, useMemo } from 'react'; +import type { ISeat, Row } from '../interfaces/venue.interfaces'; +import { useVenueStore } from '../store/seatStore'; +import { rowLetter } from '../utils/rowLetter'; +import { getTier } from '../utils/seatIndex'; +import { + SEAT_STATUS_STYLE, + describeSeatStatus, + isSeatSelectable, + resolveSeatStatus, +} from '../utils/seatStatus'; interface SeatsProps { - rows: Row[]; + rows: Row[]; } -const Seats = memo(function Seats({ rows }: SeatsProps) { - const selectedSeats = useVenueStore((s) => s.selectedSeats); - const toggleSeat = useVenueStore((s) => s.toggleSeat); - - const handleInteraction = (e: React.MouseEvent | React.KeyboardEvent, seat: ISeat, isUnavailable: boolean) => { - e.stopPropagation(); - if (!isUnavailable) { - toggleSeat(seat.id); - const target = e.currentTarget || document.getElementById(`seat-${seat.id}`); - if (target && typeof (target as HTMLElement).focus === 'function') { - (target as HTMLElement).focus(); - } - } - }; +const TIER_STROKE: Record = { + 1: 'var(--color-tier-1)', + 2: 'var(--color-tier-2)', + 3: 'var(--color-tier-3)', +}; - const handleKeyDown = (e: React.KeyboardEvent, seat: ISeat, rowIndex: number, seatIndex: number, isUnavailable: boolean) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleInteraction(e, seat, isUnavailable); - return; - } +const SEAT_SIZE = 3; +const SEAT_RADIUS = 0.35; +const LABEL_ZOOM_THRESHOLD = 2; - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - e.preventDefault(); - let nextRowIdx = rowIndex; - let nextSeatIdx = seatIndex; - - if (e.key === "ArrowRight") nextSeatIdx++; - if (e.key === "ArrowLeft") nextSeatIdx--; - if (e.key === "ArrowUp") nextRowIdx++; - if (e.key === "ArrowDown") nextRowIdx--; - - if (nextRowIdx >= 0 && nextRowIdx < rows.length) { - const targetRowSeats = rows[nextRowIdx].seats; - - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - let closest = targetRowSeats[0]; - let minDiff = Math.abs(closest.x - seat.x); - - targetRowSeats.forEach((s) => { - const diff = Math.abs(s.x - seat.x); - if (diff < minDiff) { - minDiff = diff; - closest = s; - } - }); - document.getElementById(`seat-${closest.id}`)?.focus(); - } else { - const currentRow = rows[rowIndex]; - if (nextSeatIdx >= 0 && nextSeatIdx < currentRow.seats.length) { - const targetSeat = currentRow.seats[nextSeatIdx]; - document.getElementById(`seat-${targetSeat.id}`)?.focus(); - } - } +const Seats = memo(function Seats({ rows }: SeatsProps) { + const selectedSeats = useVenueStore((s) => s.selectedSeats); + const soldSeats = useVenueStore((s) => s.soldSeats); + const holds = useVenueStore((s) => s.holds); + const simulationSeeded = useVenueStore((s) => s.simulationSeeded); + const bookings = useVenueStore((s) => s.bookings); + const viewingBookingId = useVenueStore((s) => s.viewingBookingId); + const toggleSeat = useVenueStore((s) => s.toggleSeat); + const setFeedback = useVenueStore((s) => s.setFeedback); + const showLabels = useVenueStore((s) => s.zoom >= LABEL_ZOOM_THRESHOLD); + const now = Date.now(); + + const highlightedSeatIds = useMemo(() => { + if (!viewingBookingId) return null; + const booking = bookings.find((b) => b.id === viewingBookingId); + return booking ? new Set(booking.seatIds) : null; + }, [bookings, viewingBookingId]); + + const handleInteraction = ( + e: React.MouseEvent | React.KeyboardEvent, + seat: ISeat, + isUnavailable: boolean, + ) => { + e.stopPropagation(); + if (!isUnavailable) { + toggleSeat(seat.id); + e.currentTarget.focus(); + } else { + setFeedback({ type: 'error', message: 'That seat is no longer available.' }); + } + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + seat: ISeat, + rowIndex: number, + seatIndex: number, + isUnavailable: boolean, + ) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleInteraction(e, seat, isUnavailable); + return; + } + + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault(); + let nextRowIdx = rowIndex; + let nextSeatIdx = seatIndex; + + if (e.key === 'ArrowRight') nextSeatIdx++; + if (e.key === 'ArrowLeft') nextSeatIdx--; + if (e.key === 'ArrowUp') nextRowIdx++; + if (e.key === 'ArrowDown') nextRowIdx--; + + if (nextRowIdx >= 0 && nextRowIdx < rows.length) { + const targetRowSeats = rows[nextRowIdx]?.seats; + const firstSeat = targetRowSeats?.[0]; + if (!targetRowSeats || !firstSeat) return; + + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + let closest = firstSeat; + let minDiff = Math.abs(closest.x - seat.x); + + targetRowSeats.forEach((s) => { + const diff = Math.abs(s.x - seat.x); + if (diff < minDiff) { + minDiff = diff; + closest = s; + } + }); + document.getElementById(`seat-${closest.id}`)?.focus(); + } else { + const currentRow = rows[rowIndex]; + if (currentRow && nextSeatIdx >= 0 && nextSeatIdx < currentRow.seats.length) { + const targetSeat = currentRow.seats[nextSeatIdx]; + if (targetSeat) { + document.getElementById(`seat-${targetSeat.id}`)?.focus(); } + } } - }; - - - return ( - <> - {rows.map((row, rowIndex) => - row.seats.map((seat, seatIndex) => { - const isSelected = selectedSeats.has(seat.id); - const isSold = seat.status === "sold"; - const isReserved = seat.status === "reserved"; - const isHeld = seat.status === "held"; - const isUnavailable = isSold || isReserved || isHeld; - - let fill = "white"; - let stroke = "#059669"; - - const tierColors: Record = { - 1: "#eab308", - 2: "#6b7280", - 3: "#f97316", - }; - - if (isSold) { - fill = "#d1d5db"; - stroke = "none"; - } else if (isReserved) { - fill = "#fbbf24"; - stroke = "none"; - } else if (isHeld) { - fill = "#f87171"; - stroke = "none"; - } else if (isSelected) { - fill = "#10b981"; - stroke = "none"; - } else { - stroke = tierColors[seat.priceTier] || "#059669"; - } - - const label = `Row ${row.index} Seat ${seat.col}, Price $${seat.priceTier}, ${seat.status}`; - - return ( - handleInteraction(e, seat, isUnavailable)} - onKeyDown={(e) => handleKeyDown(e, seat, rowIndex, seatIndex, isUnavailable)} - role="checkbox" - aria-checked={isSelected} - aria-label={label} - aria-disabled={isUnavailable} - tabIndex={isUnavailable ? -1 : 0} - className="focus:outline-none group" - > - - - ); - }) - )} - - ); + } + } + }; + + return ( + <> + {rows.map((row, rowIndex) => + row.seats.map((seat, seatIndex) => { + const status = resolveSeatStatus(seat, { + soldSeats, + holds, + selectedSeats, + now, + simulationSeeded, + }); + const isSelected = status === 'selected'; + const isUnavailable = !isSeatSelectable(status); + + const style = SEAT_STATUS_STYLE[status]; + const { fill, textColor } = style; + let { stroke, strokeWidth } = style; + if (status === 'available') { + stroke = TIER_STROKE[seat.priceTier] ?? stroke; + } + + const isHighlighted = highlightedSeatIds?.has(seat.id) ?? false; + if (isHighlighted) { + stroke = 'var(--color-booking-highlight)'; + strokeWidth = 0.5; + } + + const label = `Row ${rowLetter(rowIndex)} Seat ${seat.col}, Price $${getTier(seat.priceTier).price}, ${describeSeatStatus(status)}`; + + return ( + { + handleInteraction(e, seat, isUnavailable); + }} + onKeyDown={(e) => { + handleKeyDown(e, seat, rowIndex, seatIndex, isUnavailable); + }} + role="checkbox" + aria-checked={isSelected} + aria-label={label} + aria-disabled={isUnavailable} + tabIndex={isUnavailable ? -1 : 0} + className="focus:outline-none group/seat" + > + + {showLabels && ( + + {seat.col} + + )} + + ); + }), + )} + {showLabels && + rows.map((row, rowIndex) => { + const first = row.seats[0]; + const second = row.seats[1]; + if (!first || !second) return null; + const labelX = first.x + (first.x - second.x); + const labelY = first.y + (first.y - second.y); + + return ( + + {rowLetter(rowIndex)} + + ); + })} + + ); }); export default Seats; diff --git a/src/components/SeatSelectionFooter.tsx b/src/components/SeatSelectionFooter.tsx new file mode 100644 index 0000000..b6f9675 --- /dev/null +++ b/src/components/SeatSelectionFooter.tsx @@ -0,0 +1,51 @@ +import { useVenueStore } from '../store/seatStore'; + +interface SeatSelectionFooterProps { + total: number; + confirmedTotal: number | null; + onConfirmPurchase: (total: number) => void; +} + +// Rendered outside the sidebar's scrollable region (see BookingSummary.tsx) +// so Total Amount / Proceed to Pay stay reachable without scrolling past +// however many seats are currently selected. +export default function SeatSelectionFooter({ + total, + confirmedTotal, + onConfirmPurchase, +}: SeatSelectionFooterProps) { + const selectedSeatsCount = useVenueStore((s) => s.selectedSeats.size); + + return ( +
+
+ Total Amount + ${total} +
+ {confirmedTotal !== null ? ( +
+

Booking confirmed — {confirmedTotal}$

+

A confirmation has been sent to you.

+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/SeatSelectionPanel.tsx b/src/components/SeatSelectionPanel.tsx new file mode 100644 index 0000000..2d514a1 --- /dev/null +++ b/src/components/SeatSelectionPanel.tsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { buildSeatIndex } from '../utils/seatIndex'; +import ConfirmDialog from './ConfirmDialog'; +import CheckoutTimer from './CheckoutTimer'; + +interface SeatSelectionPanelProps { + venue: Venue; +} + +export default function SeatSelectionPanel({ venue }: SeatSelectionPanelProps) { + const selectedSeats = useVenueStore((s) => s.selectedSeats); + const clearSelection = useVenueStore((s) => s.clearSelection); + const [showClearConfirm, setShowClearConfirm] = useState(false); + + const seatIndex = useMemo(() => buildSeatIndex(venue), [venue]); + + const handleClear = () => { + clearSelection(); + setShowClearConfirm(false); + }; + + const selectedSeatDetails = useMemo(() => { + const details: { id: string; label: string; price: number; tierName: string }[] = []; + selectedSeats.forEach((seatId) => { + const entry = seatIndex.get(seatId); + if (!entry) return; + details.push({ + id: seatId, + label: `${entry.sectionLabel} | Row ${entry.rowLabel} | Seat ${entry.seatCol}`, + price: entry.price, + tierName: entry.tierName, + }); + }); + return details; + }, [seatIndex, selectedSeats]); + + return ( +
+ +

+ Selected Seats ({selectedSeats.size}/8) + {selectedSeats.size > 0 && ( + + )} +

+ +
+ {selectedSeatDetails.map((item) => ( +
+
+

{item.label}

+

{item.tierName}

+
+ ${item.price} +
+ ))} +
+ + {showClearConfirm && ( + { + setShowClearConfirm(false); + }} + /> + )} +
+ ); +} diff --git a/src/components/SeatStatusLegend.test.tsx b/src/components/SeatStatusLegend.test.tsx new file mode 100644 index 0000000..c5ea445 --- /dev/null +++ b/src/components/SeatStatusLegend.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import SeatStatusLegend from './SeatStatusLegend'; + +describe('SeatStatusLegend', () => { + it('renders all four statuses', () => { + render(); + const region = document.querySelector('[role="region"]'); + expect(region).not.toBeNull(); + expect(region?.textContent).toContain('Selected'); + expect(region?.textContent).toContain('Sold'); + expect(region?.textContent).toContain('Reserved'); + expect(region?.textContent).toContain('Held'); + }); +}); diff --git a/src/components/SeatStatusLegend.tsx b/src/components/SeatStatusLegend.tsx new file mode 100644 index 0000000..74c24a9 --- /dev/null +++ b/src/components/SeatStatusLegend.tsx @@ -0,0 +1,26 @@ +const STATUSES = [ + { key: 'selected', label: 'Selected', swatchClass: 'bg-seat-selected' }, + { key: 'sold', label: 'Sold', swatchClass: 'bg-seat-sold' }, + { key: 'reserved', label: 'Reserved', swatchClass: 'bg-seat-reserved' }, + { key: 'held', label: 'Held', swatchClass: 'bg-seat-held' }, +] as const; + +export default function SeatStatusLegend() { + return ( +
+

Seat Status

+
+ {STATUSES.map((status) => ( +
+
+ {status.label} +
+ ))} +
+
+ ); +} diff --git a/src/components/Section.tsx b/src/components/Section.tsx index ab68bce..cff9578 100644 --- a/src/components/Section.tsx +++ b/src/components/Section.tsx @@ -1,154 +1,197 @@ -import { memo, useMemo, useEffect } from "react"; -import { useVenueStore } from "../store/seatStore"; -import type { Section } from "../interfaces/venue.interfaces"; -import Seats from "./Seat"; - -function Section({ section }: { section: Section }) { - const activeSectionId = useVenueStore( - (s) => s.activeSectionId +import { memo, useMemo, useEffect } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import type { Section } from '../interfaces/venue.interfaces'; +import type { VenueContentBounds } from '../utils/venueBounds'; +import { computeFitZoom, SECTION_FOCUS_TRANSITION_MS } from '../utils/viewTransform'; +import Seats from './Seat'; + +const FOCUS_PADDING = 60; + +function Section({ section, bounds }: { section: Section; bounds: VenueContentBounds }) { + const activeSectionId = useVenueStore((s) => s.activeSectionId); + const setActiveSection = useVenueStore((s) => s.setActiveSection); + const mapFocusSeatIds = useVenueStore((s) => s.mapFocusSeatIds); + + const isActive = activeSectionId === section.id; + + const focusSeatIds = useMemo(() => { + return mapFocusSeatIds ? new Set(mapFocusSeatIds) : null; + }, [mapFocusSeatIds]); + + const { labelPos, explodeOffset } = useMemo(() => { + const emptyResult = { labelPos: { x: 0, y: 0 }, explodeOffset: { x: 0, y: 0 } }; + + const firstRow = section.rows[0]; + const lastRow = section.rows[section.rows.length - 1]; + if (!firstRow || !lastRow) return emptyResult; + + const firstRowMid = firstRow.seats[Math.floor(firstRow.seats.length / 2)]; + const lastRowMid = lastRow.seats[Math.floor(lastRow.seats.length / 2)]; + if (!firstRowMid || !lastRowMid) return emptyResult; + + const centerX = (firstRowMid.x + lastRowMid.x) / 2; + const centerY = (firstRowMid.y + lastRowMid.y) / 2; + const labelPosition = { x: centerX, y: centerY }; + + const venueCenterX = 1200; + const venueCenterY = 1200; + const dx = centerX - venueCenterX; + const dy = centerY - venueCenterY; + const dist = Math.sqrt(dx * dx + dy * dy); + + const gap = 40; + let offX = 0; + let offY = 0; + + if (dist > 0) { + offX = (dx / dist) * gap; + offY = (dy / dist) * gap; + } + + return { + labelPos: labelPosition, + explodeOffset: { x: offX, y: offY }, + }; + }, [section]); + + useEffect(() => { + if (!isActive) return; + + const focusSeats = focusSeatIds + ? section.rows.flatMap((row) => row.seats.filter((seat) => focusSeatIds.has(seat.id))) + : []; + const seatsForBounds = + focusSeats.length > 0 ? focusSeats : section.rows.flatMap((r) => r.seats); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + seatsForBounds.forEach((seat) => { + minX = Math.min(minX, seat.x); + minY = Math.min(minY, seat.y); + maxX = Math.max(maxX, seat.x); + maxY = Math.max(maxY, seat.y); + }); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const mapContainer = document.getElementById('map-viewport'); + if (!mapContainer) return; + + const focusZoom = computeFitZoom( + { + minX: 0, + minY: 0, + width: maxX - minX + FOCUS_PADDING * 2, + height: maxY - minY + FOCUS_PADDING * 2, + }, + mapContainer.clientWidth - 64, + mapContainer.clientHeight - 64, ); - const setZoom = useVenueStore((s) => s.setZoom); - const setActiveSection = useVenueStore((s) => s.setActiveSection); - - const isActive = activeSectionId === section.id; - - useEffect(() => { - if (isActive) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - section.rows.forEach(row => { - row.seats.forEach(seat => { - minX = Math.min(minX, seat.x); - minY = Math.min(minY, seat.y); - maxX = Math.max(maxX, seat.x); - maxY = Math.max(maxY, seat.y); - }); - }); - - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - - const timer = setTimeout(() => { - const mapContainer = document.querySelector('main.overflow-auto'); - if (mapContainer) { - const currentZoom = useVenueStore.getState().zoom; - const scrollX = (centerX * currentZoom) - (mapContainer.clientWidth / 2); - const scrollY = (centerY * currentZoom) - (mapContainer.clientHeight / 2); - - mapContainer.scrollTo({ - left: Math.max(0, scrollX), - top: Math.max(0, scrollY), - behavior: 'smooth' - }); - } - }, 150); - return () => clearTimeout(timer); - } - }, [isActive, section]); - - const coverPath = useMemo(() => { - if (!section.rows.length) return ""; - - const firstRow = section.rows[0]; - const lastRow = section.rows[section.rows.length - 1]; - - let d = `M ${firstRow.seats[0].x} ${firstRow.seats[0].y}`; - - for (let i = 1; i < firstRow.seats.length; i++) { - d += ` L ${firstRow.seats[i].x} ${firstRow.seats[i].y}`; - } - - for (let i = lastRow.seats.length - 1; i >= 0; i--) { - d += ` L ${lastRow.seats[i].x} ${lastRow.seats[i].y}`; - } - - d += " Z"; - return d; - }, [section]); - - const { labelPos, explodeOffset } = useMemo(() => { - if (!section.rows.length) return { labelPos: { x: 0, y: 0 }, explodeOffset: { x: 0, y: 0 } }; - const firstRowMid = section.rows[0].seats[Math.floor(section.rows[0].seats.length / 2)]; - const lastRowMid = section.rows[section.rows.length - 1].seats[Math.floor(section.rows[section.rows.length - 1].seats.length / 2)]; - - const centerX = (firstRowMid.x + lastRowMid.x) / 2; - const centerY = (firstRowMid.y + lastRowMid.y) / 2; - const labelPosition = { x: centerX, y: centerY }; - - const venueCenterX = 1200; - const venueCenterY = 1200; - const dx = centerX - venueCenterX; - const dy = centerY - venueCenterY; - const dist = Math.sqrt(dx * dx + dy * dy); - - const gap = 40; - let offX = 0; - let offY = 0; - - if (dist > 0) { - offX = (dx / dist) * gap; - offY = (dy / dist) * gap; + useVenueStore.getState().setView( + focusZoom, + { + x: mapContainer.clientWidth / 2 - (centerX + explodeOffset.x - bounds.minX) * focusZoom, + y: mapContainer.clientHeight / 2 - (centerY + explodeOffset.y - bounds.minY) * focusZoom, + }, + SECTION_FOCUS_TRANSITION_MS, + ); + }, [isActive, section, bounds, explodeOffset, focusSeatIds]); + + const coverPath = useMemo(() => { + const firstRow = section.rows[0]; + const lastRow = section.rows[section.rows.length - 1]; + const firstSeat = firstRow?.seats[0]; + if (!firstRow || !lastRow || !firstSeat) return ''; + + let d = `M ${firstSeat.x} ${firstSeat.y}`; + + for (let i = 1; i < firstRow.seats.length; i++) { + const s = firstRow.seats[i]; + if (s) d += ` L ${s.x} ${s.y}`; + } + + for (let i = lastRow.seats.length - 1; i >= 0; i--) { + const s = lastRow.seats[i]; + if (s) d += ` L ${s.x} ${s.y}`; + } + + d += ' Z'; + return d; + }, [section]); + + const toggleActive = () => { + setActiveSection(isActive ? null : section.id); + }; + + return ( + { + e.stopPropagation(); + toggleActive(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleActive(); } - - return { - labelPos: labelPosition, - explodeOffset: { x: offX, y: offY } - }; - }, [section]); - - return ( - + {isActive ? ( + + { - e.stopPropagation(); - const newActiveId = isActive ? null : section.id; - setActiveSection(newActiveId); - if (newActiveId) setZoom(1.5); + e.stopPropagation(); }} - style={{ cursor: "pointer" }} - transform={`translate(${explodeOffset.x} ${explodeOffset.y})`} - > - {isActive ? ( - <> - e.stopPropagation()} - style={{ cursor: "default" }} - /> - - - ) : ( - - - - - - {section.label} - - - - )} + style={{ cursor: 'default' }} + /> + - ); + ) : ( + + + + + + {section.label} + + + )} + + ); } export default memo(Section); diff --git a/src/components/SectionSkeleton.tsx b/src/components/SectionSkeleton.tsx deleted file mode 100644 index 8096620..0000000 --- a/src/components/SectionSkeleton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { memo } from "react"; -import type { Row } from "../interfaces/venue.interfaces"; - -function SectionSkeleton({ rows }: { rows: Row[] }) { - return ( - <> - {rows.map((row, rowIndex) => - row.seats - .filter((_, i) => i % 6 === 0) - .map((seat, seatIndex) => ( - - )) - )} - - ); -} - -export default memo(SectionSkeleton); diff --git a/src/components/SelectionToolbar.test.tsx b/src/components/SelectionToolbar.test.tsx new file mode 100644 index 0000000..e2f56a1 --- /dev/null +++ b/src/components/SelectionToolbar.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { useVenueStore } from '../store/seatStore'; +import { TIERS } from '../utils/seatIndex'; +import SelectionToolbar from './SelectionToolbar'; + +const venue: Venue = { + venueId: 'v', + name: 'Venue', + map: { width: 100, height: 100 }, + sections: [ + { + id: 'S-1', + label: 'Section 1', + transform: { x: 0, y: 0, scale: 1 }, + rows: [ + { + index: 1, + seats: [ + { id: 'SEAT-1', col: 1, x: 0, y: 0, priceTier: 1, status: 'available' }, + { id: 'SEAT-2', col: 2, x: 10, y: 0, priceTier: 2, status: 'available' }, + ], + }, + ], + }, + ], +}; + +const resetStore = () => { + useVenueStore.setState({ selectedSeats: new Set(), selectionExpiresAt: null }); +}; + +function getToolbar(): HTMLElement | null { + return document.querySelector('[role="toolbar"]'); +} + +describe('SelectionToolbar', () => { + beforeEach(() => { + resetStore(); + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing when no seats are selected', () => { + render(); + expect(getToolbar()).toBeNull(); + }); + + it('shows the selected count and running total', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-1', 'SEAT-2']) }); + render(); + + const toolbar = getToolbar(); + expect(toolbar).not.toBeNull(); + expect(toolbar?.textContent).toContain('2 seats selected'); + const tier1 = TIERS[1]?.price ?? 0; + const tier2 = TIERS[2]?.price ?? 0; + expect(toolbar?.textContent).toContain(`$${tier1 + tier2}`); + }); + + it('uses singular copy for exactly one seat', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-1']) }); + render(); + expect(getToolbar()?.textContent).toContain('1 seat selected'); + }); + + it('shows no countdown when there is no checkout deadline', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-1']), selectionExpiresAt: null }); + render(); + expect(getToolbar()?.querySelectorAll('svg')).toHaveLength(1); + }); + + it('shows a live countdown when a checkout deadline is set', () => { + useVenueStore.setState({ + selectedSeats: new Set(['SEAT-1']), + selectionExpiresAt: 300_000, + }); + render(); + expect(getToolbar()?.textContent).toContain('5:00'); + }); + + it('the Remove button clears the selection', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-1', 'SEAT-2']) }); + render(); + + const removeButton = Array.from(document.querySelectorAll('button')).find((b) => + b.textContent.includes('Remove'), + ); + if (!removeButton) throw new Error('Expected a "Remove" button to render.'); + fireEvent.click(removeButton); + + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + }); +}); diff --git a/src/components/SelectionToolbar.tsx b/src/components/SelectionToolbar.tsx new file mode 100644 index 0000000..9ba5fad --- /dev/null +++ b/src/components/SelectionToolbar.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import type { Venue } from '../interfaces/venue.interfaces'; +import { buildSeatIndex } from '../utils/seatIndex'; +import { useCountdown } from '../hooks/useCountdown'; +import { formatCountdown } from '../utils/formatCountdown'; +import Icon from './Icon'; + +interface SelectionToolbarProps { + venue: Venue; +} + +export default function SelectionToolbar({ venue }: SelectionToolbarProps) { + const selectedSeats = useVenueStore((s) => s.selectedSeats); + const clearSelection = useVenueStore((s) => s.clearSelection); + const selectionExpiresAt = useVenueStore((s) => s.selectionExpiresAt); + const remaining = useCountdown(selectionExpiresAt); + + const seatIndex = useMemo(() => buildSeatIndex(venue), [venue]); + const total = useMemo(() => { + let sum = 0; + selectedSeats.forEach((seatId) => { + sum += seatIndex.get(seatId)?.price ?? 0; + }); + return sum; + }, [seatIndex, selectedSeats]); + + if (selectedSeats.size === 0) return null; + + return ( +
+ + {selectedSeats.size} seat{selectedSeats.size === 1 ? '' : 's'} selected + + +
+ ); +} diff --git a/src/components/Stage.tsx b/src/components/Stage.tsx deleted file mode 100644 index 89d453f..0000000 --- a/src/components/Stage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export const Stage = () => ( - - - - STAGE - - -); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..6f644f7 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useVenueStore } from '../store/seatStore'; + +const AUTO_DISMISS_MS = 4000; + +export default function Toast() { + const feedback = useVenueStore((s) => s.feedback); + const clearFeedback = useVenueStore((s) => s.clearFeedback); + + useEffect(() => { + if (!feedback) return; + const timer = setTimeout(clearFeedback, AUTO_DISMISS_MS); + return () => { + clearTimeout(timer); + }; + }, [feedback, clearFeedback]); + + if (!feedback) return null; + + const isError = feedback.type === 'error'; + + return ( +
+
+

{feedback.message}

+ +
+
+ ); +} diff --git a/src/components/VenueMap.tsx b/src/components/VenueMap.tsx index b8e836b..0d53ad4 100644 --- a/src/components/VenueMap.tsx +++ b/src/components/VenueMap.tsx @@ -1,47 +1,88 @@ -import type { Venue } from "../interfaces/venue.interfaces"; -import Section from "./Section"; -import { useVenueStore } from "../store/seatStore"; +import { useMemo } from 'react'; +import type { Venue } from '../interfaces/venue.interfaces'; +import Section from './Section'; +import { useVenueStore } from '../store/seatStore'; +import { getVenueContentBounds } from '../utils/venueBounds'; -export default function VenueMap({ venue, zoom = 1 }: { venue: Venue; zoom?: number }) { - return ( - { - useVenueStore.getState().setActiveSection(null); - }} - > - - - +interface VenueMapProps { + venue: Venue; + zoom?: number; + pan?: { x: number; y: number }; + transitionMs?: number; +} + +export default function VenueMap({ + venue, + zoom = 1, + pan = { x: 0, y: 0 }, + transitionMs = 0, +}: VenueMapProps) { + // Size/crop the SVG to the venue's actual content extent, not the full map + // canvas - see getVenueContentBounds for why. Sections' own seat + // coordinates are unaffected (they're absolute, in the same coordinate + // space); only what portion of that space the viewBox exposes changes. + const bounds = useMemo(() => getVenueContentBounds(venue), [venue]); + + return ( +
0 ? `transform ${transitionMs}ms cubic-bezier(0.16, 1, 0.3, 1)` : 'none', + }} + > + { + useVenueStore.getState().setActiveSection(null); + }} + > + + + - STAGE - + + STAGE + + - {venue.sections.map((section) => ( -
- ))} -
- ); + {venue.sections.map((section) => ( +
+ ))} + +
+ ); } diff --git a/src/hooks/useCountdown.test.ts b/src/hooks/useCountdown.test.ts new file mode 100644 index 0000000..8d3ee32 --- /dev/null +++ b/src/hooks/useCountdown.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useCountdown } from './useCountdown'; + +describe('useCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('counts down as time advances', () => { + vi.setSystemTime(0); + const { result } = renderHook(() => useCountdown(5000)); + expect(result.current).toBe(5000); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(4000); + }); + + it('clamps at 0 and stops scheduling once the deadline passes', () => { + vi.setSystemTime(0); + const { result } = renderHook(() => useCountdown(1000)); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('returns null and schedules nothing when there is no deadline', () => { + const { result } = renderHook(() => useCountdown(null)); + expect(result.current).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears its timer on unmount', () => { + vi.setSystemTime(0); + const { unmount } = renderHook(() => useCountdown(5000)); + expect(vi.getTimerCount()).toBeGreaterThan(0); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('reschedules when the deadline changes', () => { + vi.setSystemTime(0); + const { result, rerender } = renderHook( + ({ deadline }: { deadline: number | null }) => useCountdown(deadline), + { initialProps: { deadline: 5000 } }, + ); + expect(result.current).toBe(5000); + rerender({ deadline: 10000 }); + expect(result.current).toBe(10000); + }); +}); diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..1d60008 --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +export function useCountdown(deadline: number | null): number | null { + const [remaining, setRemaining] = useState(null); + + useEffect(() => { + if (deadline === null) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRemaining(null); + return undefined; + } + + let timeoutId = 0; + + const tick = () => { + const next = Math.max(0, deadline - Date.now()); + setRemaining(next); + if (next > 0) { + const delay = next % 1000 || 1000; + timeoutId = window.setTimeout(tick, delay); + } + }; + + tick(); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [deadline]); + + return remaining; +} diff --git a/src/hooks/useHoldExpiry.ts b/src/hooks/useHoldExpiry.ts new file mode 100644 index 0000000..3ca8bee --- /dev/null +++ b/src/hooks/useHoldExpiry.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useVenueStore } from '../store/seatStore'; +import { EXPIRY_SLACK_MS, nextDeadline } from '../utils/holdProtocol'; + +export function useHoldExpiry(): void { + useEffect(() => { + let timeoutId = 0; + + const scheduleNext = () => { + window.clearTimeout(timeoutId); + const { holds, selectionExpiresAt } = useVenueStore.getState(); + const deadline = nextDeadline(holds, selectionExpiresAt); + if (deadline === null) return; + + const delay = Math.max(0, deadline - Date.now()) + EXPIRY_SLACK_MS; + timeoutId = window.setTimeout(() => { + useVenueStore.getState().runExpiry(Date.now()); + scheduleNext(); + }, delay); + }; + + scheduleNext(); + + const unsubscribe = useVenueStore.subscribe(() => { + scheduleNext(); + }); + + const handleVisibility = () => { + if (document.visibilityState === 'visible') { + useVenueStore.getState().runExpiry(Date.now()); + scheduleNext(); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + + return () => { + window.clearTimeout(timeoutId); + unsubscribe(); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, []); +} diff --git a/src/hooks/useHoldSync.test.ts b/src/hooks/useHoldSync.test.ts new file mode 100644 index 0000000..79ec2f5 --- /dev/null +++ b/src/hooks/useHoldSync.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { HoldMessage, Venue } from '../interfaces/venue.interfaces'; +import { useVenueStore } from '../store/seatStore'; +import { SYNC_TIMEOUT_MS } from '../utils/holdProtocol'; +import { createFakeBus } from '../test/fakeBroadcastChannel'; +import { createHoldChannel } from '../utils/holdChannel'; +import { getSessionId } from '../utils/sessionId'; +import { useHoldSync } from './useHoldSync'; + +const venue: Venue = { + venueId: 'v', + name: 'Venue', + map: { width: 100, height: 100 }, + sections: [ + { + id: 'S-1', + label: 'Section 1', + transform: { x: 0, y: 0, scale: 1 }, + rows: [ + { + index: 1, + seats: [ + { id: 'H1', col: 1, x: 0, y: 0, priceTier: 1, status: 'held' }, + { id: 'A1', col: 2, x: 1, y: 0, priceTier: 1, status: 'available' }, + ], + }, + ], + }, + ], +}; + +const resetStore = () => { + useVenueStore.setState({ + selectedSeats: new Set(), + soldSeats: new Set(), + holds: new Map(), + selectionExpiresAt: null, + simulationSeeded: false, + feedback: null, + }); +}; + +describe('useHoldSync', () => { + let mySessionId: string; + + beforeEach(() => { + resetStore(); + mySessionId = getSessionId(); + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('posts a hello message on mount', () => { + const { factory } = createFakeBus(); + const received: HoldMessage[] = []; + createHoldChannel('peer', (msg) => received.push(msg), factory); + + renderHook(() => { + useHoldSync(venue, factory); + }); + + expect(received.some((m) => m.type === 'hello')).toBe(true); + }); + + it('adopts an incumbent’s state reply and does not seed its own simulation on top', () => { + const { factory } = createFakeBus(); + const peer = createHoldChannel( + 'peer', + (msg) => { + if (msg.type === 'hello') { + peer.post({ + type: 'state', + from: 'peer', + sentAt: Date.now(), + to: msg.from, + holds: [{ seatId: 'A1', owner: 'peer', expiresAt: 999_000 }], + }); + } + }, + factory, + ); + + renderHook(() => { + useHoldSync(venue, factory); + }); + + expect(useVenueStore.getState().holds.get('A1')?.owner).toBe('peer'); + expect(useVenueStore.getState().simulationSeeded).toBe(true); + expect(useVenueStore.getState().holds.has('H1')).toBe(false); + }); + + it('seeds its own simulation when no incumbent replies within the sync timeout', () => { + const { factory } = createFakeBus(); + renderHook(() => { + useHoldSync(venue, factory); + }); + + expect(useVenueStore.getState().simulationSeeded).toBe(false); + vi.advanceTimersByTime(SYNC_TIMEOUT_MS); + expect(useVenueStore.getState().simulationSeeded).toBe(true); + expect(useVenueStore.getState().holds.has('H1')).toBe(true); + }); + + it('seeds immediately, with no wait, when the channel is unavailable', () => { + renderHook(() => { + useHoldSync(venue, () => null); + }); + expect(useVenueStore.getState().simulationSeeded).toBe(true); + expect(useVenueStore.getState().holds.has('H1')).toBe(true); + }); + + it('broadcasts a hold message when the local selection changes', () => { + const { factory } = createFakeBus(); + const received: HoldMessage[] = []; + createHoldChannel('peer', (msg) => received.push(msg), factory); + renderHook(() => { + useHoldSync(venue, factory); + }); + + useVenueStore.getState().toggleSeat('A1'); + + const holdMessages = received.filter((m) => m.type === 'hold'); + expect(holdMessages.length).toBeGreaterThan(0); + expect(holdMessages.at(-1)?.seatIds).toEqual(['A1']); + }); + + it('confirmPurchase broadcasts sold, then an empty hold', () => { + const { factory } = createFakeBus(); + const received: HoldMessage[] = []; + createHoldChannel('peer', (msg) => received.push(msg), factory); + renderHook(() => { + useHoldSync(venue, factory); + }); + + useVenueStore.getState().toggleSeat('A1'); + useVenueStore.getState().confirmPurchase(100); + + expect(received.some((m) => m.type === 'sold')).toBe(true); + const holdMessages = received.filter((m) => m.type === 'hold'); + expect(holdMessages.at(-1)?.seatIds).toEqual([]); + }); + + it('posts goodbye on pagehide', () => { + const { factory } = createFakeBus(); + const received: HoldMessage[] = []; + createHoldChannel('peer', (msg) => received.push(msg), factory); + renderHook(() => { + useHoldSync(venue, factory); + }); + + window.dispatchEvent(new Event('pagehide')); + + expect(received.some((m) => m.type === 'goodbye')).toBe(true); + }); + + it('ingesting a remote sold message does not re-broadcast it (no ping-pong)', () => { + const { factory } = createFakeBus(); + const peer = createHoldChannel('peer', () => undefined, factory); + const received: HoldMessage[] = []; + createHoldChannel('observer', (msg) => received.push(msg), factory); + + renderHook(() => { + useHoldSync(venue, factory); + }); + + peer.post({ type: 'sold', from: 'peer', sentAt: Date.now(), seatIds: ['A1'] }); + + expect(received.some((m) => m.type === 'sold' && m.from === mySessionId)).toBe(false); + expect(useVenueStore.getState().soldSeats.has('A1')).toBe(true); + }); +}); diff --git a/src/hooks/useHoldSync.ts b/src/hooks/useHoldSync.ts new file mode 100644 index 0000000..a477b05 --- /dev/null +++ b/src/hooks/useHoldSync.ts @@ -0,0 +1,158 @@ +import { useEffect } from 'react'; +import type { HoldMessage, Venue } from '../interfaces/venue.interfaces'; +import { useVenueStore } from '../store/seatStore'; +import { + SYNC_TIMEOUT_MS, + adoptRemoteState, + ingestPeerHold, + toWireHolds, +} from '../utils/holdProtocol'; +import type { BroadcastChannelLike } from '../utils/holdChannel'; +import { createHoldChannel } from '../utils/holdChannel'; +import { seedSimulatedHolds } from '../utils/holdSimulation'; +import { getSessionId } from '../utils/sessionId'; + +export function useHoldSync( + venue: Venue, + channelFactory?: (name: string) => BroadcastChannelLike | null, +): void { + useEffect(() => { + const sessionId = getSessionId(); + let seeded = false; + + const seedFreshSimulation = () => { + if (seeded) return; + seeded = true; + const holds = seedSimulatedHolds(venue, Date.now(), useVenueStore.getState().selectedSeats); + useVenueStore.getState().seedSimulation(holds); + }; + + let lastSelected = new Set(); + let lastExpiresAt: number | null = null; + let lastSold = new Set(); + + const postHold = () => { + const state = useVenueStore.getState(); + channel.post({ + type: 'hold', + from: sessionId, + sentAt: Date.now(), + seatIds: Array.from(state.selectedSeats), + expiresAt: state.selectionExpiresAt ?? 0, + }); + }; + + const handleMessage = (message: HoldMessage) => { + const state = useVenueStore.getState(); + + switch (message.type) { + case 'hello': { + const mine = Array.from(state.selectedSeats, (seatId) => ({ + seatId, + owner: sessionId, + expiresAt: state.selectionExpiresAt ?? 0, + })); + channel.post({ + type: 'state', + from: sessionId, + sentAt: Date.now(), + to: message.from, + holds: [...toWireHolds(state.holds), ...mine], + }); + return; + } + + case 'state': { + if (message.to !== sessionId || seeded) return; + seeded = true; + const { holds, lostSeatIds } = adoptRemoteState( + state.selectedSeats, + sessionId, + message.holds, + ); + useVenueStore.getState().seedSimulation(holds); + if (lostSeatIds.length > 0) useVenueStore.getState().releaseLostSeats(lostSeatIds); + return; + } + + case 'hold': { + const result = ingestPeerHold(state.holds, state.selectedSeats, sessionId, message); + useVenueStore.getState().setHolds(result.holds); + if (result.lostSeatIds.length > 0) { + useVenueStore.getState().releaseLostSeats(result.lostSeatIds); + } + if (result.mustRepost) postHold(); + return; + } + + case 'sold': { + // Update lastSold before applying, or this bounces straight back onto the channel. + message.seatIds.forEach((id) => lastSold.add(id)); + useVenueStore.getState().applyRemoteSold(message.seatIds); + return; + } + + case 'goodbye': { + const result = ingestPeerHold(state.holds, state.selectedSeats, sessionId, { + from: message.from, + seatIds: [], + expiresAt: 0, + }); + useVenueStore.getState().setHolds(result.holds); + return; + } + } + }; + + const channel = createHoldChannel(sessionId, handleMessage, channelFactory); + + const broadcastIfChanged = () => { + const state = useVenueStore.getState(); + + const selectionChanged = + state.selectionExpiresAt !== lastExpiresAt || + state.selectedSeats.size !== lastSelected.size || + Array.from(state.selectedSeats).some((id) => !lastSelected.has(id)); + + if (selectionChanged) { + lastSelected = new Set(state.selectedSeats); + lastExpiresAt = state.selectionExpiresAt; + postHold(); + } + + const newlySold = Array.from(state.soldSeats).filter((id) => !lastSold.has(id)); + if (newlySold.length > 0) { + lastSold = new Set(state.soldSeats); + channel.post({ type: 'sold', from: sessionId, sentAt: Date.now(), seatIds: newlySold }); + } + }; + + const unsubscribe = useVenueStore.subscribe(broadcastIfChanged); + + channel.post({ + type: 'hello', + from: sessionId, + sentAt: Date.now(), + ownedSeatIds: Array.from(useVenueStore.getState().selectedSeats), + }); + + let syncTimeoutId = 0; + if (channel.available) { + syncTimeoutId = window.setTimeout(seedFreshSimulation, SYNC_TIMEOUT_MS); + } else { + seedFreshSimulation(); + } + + const handlePageHide = () => { + channel.post({ type: 'goodbye', from: sessionId, sentAt: Date.now() }); + }; + window.addEventListener('pagehide', handlePageHide); + + return () => { + window.clearTimeout(syncTimeoutId); + unsubscribe(); + window.removeEventListener('pagehide', handlePageHide); + channel.close(); + }; + }, [venue, channelFactory]); +} diff --git a/src/hooks/useVenue.ts b/src/hooks/useVenue.ts index 84a4aee..7780346 100644 --- a/src/hooks/useVenue.ts +++ b/src/hooks/useVenue.ts @@ -1,26 +1,64 @@ -import { useEffect, useState } from "react"; -import type { Venue } from "../interfaces/venue.interfaces"; +import { useCallback, useEffect, useState } from 'react'; +import type { Venue } from '../interfaces/venue.interfaces'; + +// A minimal structural check on the fetched JSON before it's trusted as a +// `Venue` - not a full per-seat schema (15,000 seats would make that slow +// for little benefit), just enough to catch the realistic failure modes for +// a static file: wrong file, an error page parsed as JSON, or a completely +// different shape. +function isVenue(data: unknown): data is Venue { + return ( + typeof data === 'object' && + data !== null && + 'venueId' in data && + 'name' in data && + 'sections' in data && + typeof data.venueId === 'string' && + typeof data.name === 'string' && + Array.isArray(data.sections) + ); +} export function useVenue() { - const [venue, setVenue] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [venue, setVenue] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryToken, setRetryToken] = useState(0); + + const refetch = useCallback(() => { + setLoading(true); + setError(null); + setRetryToken((n) => n + 1); + }, []); - useEffect(() => { - fetchVenue(); - }, []); + useEffect(() => { + let cancelled = false; const fetchVenue = async () => { - try { - const response = await fetch('/venue.json'); - const data = await response.json(); - setVenue(data); - setLoading(false); - } catch (error) { - setError('Failed to load venue data'); - setLoading(false); + try { + const response = await fetch('/venue.json'); + const data: unknown = await response.json(); + if (cancelled) return; + if (!isVenue(data)) { + setError('Failed to load venue data'); + setLoading(false); + return; } + setVenue(data); + setLoading(false); + } catch { + if (cancelled) return; + setError('Failed to load venue data'); + setLoading(false); + } + }; + + void fetchVenue(); + + return () => { + cancelled = true; }; + }, [retryToken]); - return { venue, loading, error }; -} \ No newline at end of file + return { venue, loading, error, refetch }; +} diff --git a/src/index.css b/src/index.css index a461c50..280d995 100644 --- a/src/index.css +++ b/src/index.css @@ -1 +1,115 @@ -@import "tailwindcss"; \ No newline at end of file +@import 'tailwindcss'; + +@theme { + /* Seat status */ + --color-seat-available: #ffffff; + --color-seat-available-stroke: #059669; + --color-seat-selected: #10b981; + --color-seat-sold: #d1d5db; + --color-seat-reserved: #fbbf24; + --color-seat-held: #f87171; + + /* Price tiers (used both as SVG stroke colors and legend swatch borders) */ + --color-tier-1: #eab308; + --color-tier-2: #6b7280; + --color-tier-3: #f97316; + + /* Brand header gradient */ + --color-brand-from: #60a5fa; + --color-brand-to: #818cf8; + + --color-booking-highlight: #7c3aed; + --color-hold-warning: #d97706; +} + +/* Plays once, on selection. */ +@keyframes seat-select-pop { + 0% { + transform: scale(0.75); + opacity: 0.35; + } + 55% { + transform: scale(1.2); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.seat-select-pop { + transform-box: fill-box; + transform-origin: center; + animation: seat-select-pop 300ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .seat-select-pop { + animation: none; + } +} + +/* Plays on mount, opening or closing a section. */ +@keyframes section-activate { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.section-activate { + animation: section-activate 250ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .section-activate { + animation: none; + } +} + +/* Loops continuously while a booking's seats are highlighted. */ +@keyframes seat-highlight-pulse { + 0%, + 100% { + filter: drop-shadow(0 0 0 color-mix(in srgb, var(--color-booking-highlight) 70%, transparent)); + } + 50% { + filter: drop-shadow( + 0 0 0.6px color-mix(in srgb, var(--color-booking-highlight) 70%, transparent) + ); + } +} + +.seat-highlight-pulse { + animation: seat-highlight-pulse 1.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .seat-highlight-pulse { + animation: none; + } +} + +/* Loops during the checkout timer's final 30 seconds. */ +@keyframes hold-timer-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.hold-timer-pulse { + animation: hold-timer-pulse 1s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .hold-timer-pulse { + animation: none; + } +} diff --git a/src/interfaces/venue.interfaces.ts b/src/interfaces/venue.interfaces.ts index f9d6e8c..ef85c5b 100644 --- a/src/interfaces/venue.interfaces.ts +++ b/src/interfaces/venue.interfaces.ts @@ -1,35 +1,61 @@ -export type SeatStatus = "available" | "reserved" | "sold" | "held"; +export type SeatStatus = 'available' | 'reserved' | 'sold' | 'held'; export interface ISeat { - id: string; - col: number; - x: number; - y: number; - priceTier: number; - status: SeatStatus; + id: string; + col: number; + x: number; + y: number; + priceTier: number; + status: SeatStatus; } export interface Row { - index: number; - seats: ISeat[]; + index: number; + seats: ISeat[]; } export interface Section { - id: string; - label: string; - transform: { - x: number; - y: number; - scale: number; - }; - rows: Row[]; + id: string; + label: string; + transform: { + x: number; + y: number; + scale: number; + }; + rows: Row[]; } export interface Venue { - venueId: string; - name: string; - map: { - width: number; - height: number; - }; - sections: Section[]; -} \ No newline at end of file + venueId: string; + name: string; + map: { + width: number; + height: number; + }; + sections: Section[]; +} + +export const SIMULATED_OWNER = '__simulation__'; + +export interface SeatHold { + seatId: string; + owner: string; + expiresAt: number; +} + +export type HoldMap = ReadonlyMap; + +export type EffectiveSeatStatus = SeatStatus | 'selected'; + +export interface WireHold { + seatId: string; + owner: string; + expiresAt: number; +} + +// `hold` carries an owner's entire current set, not a delta - no separate `release` message. +export type HoldMessage = + | { type: 'hello'; from: string; sentAt: number; ownedSeatIds: string[] } + | { type: 'state'; from: string; sentAt: number; to: string; holds: WireHold[] } + | { type: 'hold'; from: string; sentAt: number; seatIds: string[]; expiresAt: number } + | { type: 'sold'; from: string; sentAt: number; seatIds: string[] } + | { type: 'goodbye'; from: string; sentAt: number }; diff --git a/src/main.tsx b/src/main.tsx index bef5202..8238fcc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,15 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; -createRoot(document.getElementById('root')!).render( +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Root element #root not found - check index.html.'); +} + +createRoot(rootElement).render( , -) +); diff --git a/src/store/seatStore.test.ts b/src/store/seatStore.test.ts new file mode 100644 index 0000000..741fb7b --- /dev/null +++ b/src/store/seatStore.test.ts @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { HoldMap } from '../interfaces/venue.interfaces'; +import { SELECTION_HOLD_MS } from '../utils/holdProtocol'; +import { MAX_SELECTABLE_SEATS, useVenueStore } from './seatStore'; + +function getPersistedRaw(): string { + const raw = localStorage.getItem('venue-storage'); + if (raw === null) throw new Error('Expected venue-storage to be persisted in localStorage.'); + return raw; +} + +const resetStore = () => { + useVenueStore.setState({ + activeSectionId: null, + selectedSeats: new Set(), + soldSeats: new Set(), + bookings: [], + viewingBookingId: null, + zoom: 0.4, + feedback: null, + holds: new Map(), + selectionExpiresAt: null, + simulationSeeded: false, + }); + localStorage.clear(); +}; + +function holdMap(seatId: string, expiresAt: number, owner = 'peer'): HoldMap { + return new Map([[seatId, { seatId, owner, expiresAt }]]); +} + +describe('seatStore', () => { + beforeEach(resetStore); + + it('selects a seat via toggleSeat', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(true); + }); + + it('deselects an already-selected seat via toggleSeat', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(false); + }); + + it('enforces the max-seat rule and surfaces feedback instead of throwing/alerting', () => { + for (let i = 1; i <= MAX_SELECTABLE_SEATS; i++) { + useVenueStore.getState().toggleSeat(`SEAT-${i}`); + } + expect(useVenueStore.getState().selectedSeats.size).toBe(MAX_SELECTABLE_SEATS); + + useVenueStore.getState().toggleSeat('SEAT-overflow'); + + expect(useVenueStore.getState().selectedSeats.size).toBe(MAX_SELECTABLE_SEATS); + expect(useVenueStore.getState().selectedSeats.has('SEAT-overflow')).toBe(false); + expect(useVenueStore.getState().feedback).toEqual({ + type: 'error', + message: `You can select a maximum of ${MAX_SELECTABLE_SEATS} seats.`, + }); + }); + + it('clears the selection via clearSelection', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().toggleSeat('SEAT-2'); + useVenueStore.getState().clearSelection(); + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + }); + + it('clearSelection nulls the checkout deadline', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().clearSelection(); + expect(useVenueStore.getState().selectionExpiresAt).toBeNull(); + }); + + describe('checkout hold timing', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('the first seat added starts the checkout deadline', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().selectionExpiresAt).toBe(SELECTION_HOLD_MS); + }); + + it('adding a second seat does not extend the deadline', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + vi.advanceTimersByTime(10_000); + useVenueStore.getState().toggleSeat('SEAT-2'); + expect(useVenueStore.getState().selectionExpiresAt).toBe(SELECTION_HOLD_MS); + }); + + it('refuses to select a seat with a live foreign hold, with feedback', () => { + useVenueStore.setState({ holds: holdMap('SEAT-1', 5_000) }); + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(false); + expect(useVenueStore.getState().feedback?.type).toBe('error'); + }); + + it('allows selecting a seat whose hold has already lapsed', () => { + useVenueStore.setState({ holds: holdMap('SEAT-1', 500) }); + vi.setSystemTime(1_000); + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(true); + }); + }); + + it('confirmPurchase moves selected seats into soldSeats and empties the cart', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().toggleSeat('SEAT-2'); + useVenueStore.getState().confirmPurchase(6000); + + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + expect(useVenueStore.getState().soldSeats.has('SEAT-1')).toBe(true); + expect(useVenueStore.getState().soldSeats.has('SEAT-2')).toBe(true); + }); + + it('confirmPurchase accumulates across multiple purchases rather than overwriting', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().confirmPurchase(3000); + + useVenueStore.getState().toggleSeat('SEAT-2'); + useVenueStore.getState().confirmPurchase(3000); + + expect(useVenueStore.getState().soldSeats.has('SEAT-1')).toBe(true); + expect(useVenueStore.getState().soldSeats.has('SEAT-2')).toBe(true); + }); + + it('confirmPurchase records a Booking grouping the just-bought seats and views it', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().toggleSeat('SEAT-2'); + useVenueStore.getState().confirmPurchase(6000); + + const { bookings, viewingBookingId } = useVenueStore.getState(); + expect(bookings).toHaveLength(1); + expect(bookings[0]?.seatIds.sort()).toEqual(['SEAT-1', 'SEAT-2']); + expect(bookings[0]?.total).toBe(6000); + expect(viewingBookingId).toBe(bookings[0]?.id); + }); + + it('confirmPurchase prepends new bookings so the newest is first', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().confirmPurchase(3000); + useVenueStore.getState().toggleSeat('SEAT-2'); + useVenueStore.getState().confirmPurchase(3000); + + const { bookings } = useVenueStore.getState(); + expect(bookings).toHaveLength(2); + expect(bookings[0]?.seatIds).toEqual(['SEAT-2']); + expect(bookings[1]?.seatIds).toEqual(['SEAT-1']); + }); + + it('confirmPurchase returns the booking and nulls the checkout deadline', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + const booking = useVenueStore.getState().confirmPurchase(7000); + expect(booking?.seatIds).toEqual(['SEAT-1']); + expect(useVenueStore.getState().selectionExpiresAt).toBeNull(); + }); + + it('confirmPurchase refuses an already-expired hold: no booking, cart cleared, error feedback', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.setState({ selectionExpiresAt: Date.now() - 1 }); + + const booking = useVenueStore.getState().confirmPurchase(7000); + + expect(booking).toBeNull(); + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + expect(useVenueStore.getState().soldSeats.has('SEAT-1')).toBe(false); + expect(useVenueStore.getState().bookings).toHaveLength(0); + expect(useVenueStore.getState().feedback?.type).toBe('error'); + }); + + it('confirmPurchase still succeeds when selectionExpiresAt is null (e.g. state seeded directly)', () => { + useVenueStore.setState({ selectedSeats: new Set(['SEAT-1']), selectionExpiresAt: null }); + const booking = useVenueStore.getState().confirmPurchase(7000); + expect(booking).not.toBeNull(); + expect(useVenueStore.getState().soldSeats.has('SEAT-1')).toBe(true); + }); + + it('releaseLostSeats drops just the named seats and nulls the deadline if the cart empties', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().releaseLostSeats(['SEAT-1']); + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + expect(useVenueStore.getState().selectionExpiresAt).toBeNull(); + expect(useVenueStore.getState().feedback?.type).toBe('error'); + }); + + it('applyRemoteSold marks seats sold, drops their holds, and removes them from my selection', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.setState({ holds: holdMap('SEAT-2', 9_000) }); + + useVenueStore.getState().applyRemoteSold(['SEAT-1', 'SEAT-2']); + + const state = useVenueStore.getState(); + expect(state.soldSeats.has('SEAT-1')).toBe(true); + expect(state.soldSeats.has('SEAT-2')).toBe(true); + expect(state.holds.has('SEAT-2')).toBe(false); + expect(state.selectedSeats.has('SEAT-1')).toBe(false); + expect(state.selectionExpiresAt).toBeNull(); + }); + + it('seedSimulation is a no-op once simulationSeeded is already true', () => { + useVenueStore.setState({ simulationSeeded: true, holds: holdMap('SEAT-1', 9_000) }); + useVenueStore.getState().seedSimulation(holdMap('SEAT-2', 9_000)); + expect(useVenueStore.getState().holds.has('SEAT-1')).toBe(true); + expect(useVenueStore.getState().holds.has('SEAT-2')).toBe(false); + }); + + describe('runExpiry', () => { + it('leaves the holds map reference identical when nothing has lapsed', () => { + const holds = holdMap('SEAT-1', 9_000); + useVenueStore.setState({ holds }); + useVenueStore.getState().runExpiry(1_000); + // toBe, not toEqual - reference identity is what skips a re-render. + expect(useVenueStore.getState().holds).toBe(holds); + }); + + it('prunes a lapsed hold', () => { + useVenueStore.setState({ holds: holdMap('SEAT-1', 500) }); + useVenueStore.getState().runExpiry(1_000); + expect(useVenueStore.getState().holds.size).toBe(0); + }); + + it('releases an expired selection with feedback', () => { + useVenueStore.setState({ + selectedSeats: new Set(['SEAT-1']), + selectionExpiresAt: 500, + }); + useVenueStore.getState().runExpiry(1_000); + expect(useVenueStore.getState().selectedSeats.size).toBe(0); + expect(useVenueStore.getState().selectionExpiresAt).toBeNull(); + expect(useVenueStore.getState().feedback?.type).toBe('info'); + }); + + it('does nothing when the selection deadline is still in the future', () => { + useVenueStore.setState({ + selectedSeats: new Set(['SEAT-1']), + selectionExpiresAt: 9_000, + }); + useVenueStore.getState().runExpiry(1_000); + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(true); + }); + }); + + it('toggleSeat clears any booking currently being viewed', () => { + useVenueStore.getState().viewBooking('BK-1'); + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(useVenueStore.getState().viewingBookingId).toBeNull(); + }); + + it('selectSeatBlock replaces the current selection rather than merging into it', () => { + useVenueStore.getState().toggleSeat('SEAT-unrelated'); + useVenueStore.getState().selectSeatBlock(['SEAT-1', 'SEAT-2', 'SEAT-3'], 'S-1'); + + const { selectedSeats } = useVenueStore.getState(); + expect(Array.from(selectedSeats).sort()).toEqual(['SEAT-1', 'SEAT-2', 'SEAT-3']); + }); + + it('selectSeatBlock sets activeSectionId in the same update', () => { + useVenueStore.getState().selectSeatBlock(['SEAT-1'], 'S-4'); + expect(useVenueStore.getState().activeSectionId).toBe('S-4'); + }); + + it('selectSeatBlock clears any booking currently being viewed', () => { + useVenueStore.getState().viewBooking('BK-1'); + useVenueStore.getState().selectSeatBlock(['SEAT-1'], 'S-1'); + expect(useVenueStore.getState().viewingBookingId).toBeNull(); + }); + + it('selectSeatBlock refuses more than MAX_SELECTABLE_SEATS ids and leaves the selection untouched', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + const tooMany = Array.from({ length: MAX_SELECTABLE_SEATS + 1 }, (_, i) => `SEAT-block-${i}`); + + useVenueStore.getState().selectSeatBlock(tooMany, 'S-1'); + + expect(Array.from(useVenueStore.getState().selectedSeats)).toEqual(['SEAT-1']); + expect(useVenueStore.getState().feedback).toEqual({ + type: 'error', + message: `You can select a maximum of ${MAX_SELECTABLE_SEATS} seats.`, + }); + }); + + it('selectSeatBlock with an empty array leaves the selection untouched', () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().selectSeatBlock([], 'S-1'); + expect(Array.from(useVenueStore.getState().selectedSeats)).toEqual(['SEAT-1']); + }); + + it('a seat chosen via selectSeatBlock can still be individually deselected via toggleSeat', () => { + useVenueStore.getState().selectSeatBlock(['SEAT-1', 'SEAT-2'], 'S-1'); + useVenueStore.getState().toggleSeat('SEAT-1'); + expect(Array.from(useVenueStore.getState().selectedSeats)).toEqual(['SEAT-2']); + }); + + it('viewBooking and clearViewingBooking control which booking is highlighted', () => { + useVenueStore.getState().viewBooking('BK-1'); + expect(useVenueStore.getState().viewingBookingId).toBe('BK-1'); + useVenueStore.getState().clearViewingBooking(); + expect(useVenueStore.getState().viewingBookingId).toBeNull(); + }); + + it('persists and restores selected seats across a save/reload cycle', async () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().toggleSeat('SEAT-2'); + + await Promise.resolve(); + + const raw = getPersistedRaw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = JSON.parse(raw) as unknown as { + state: { selectedSeats: string[]; soldSeats: string[] }; + }; + expect(parsed.state.selectedSeats.sort()).toEqual(['SEAT-1', 'SEAT-2']); + + useVenueStore.setState({ selectedSeats: new Set() }); + const rehydrated = new Set(parsed.state.selectedSeats); + useVenueStore.setState({ selectedSeats: rehydrated }); + + expect(useVenueStore.getState().selectedSeats.has('SEAT-1')).toBe(true); + expect(useVenueStore.getState().selectedSeats.has('SEAT-2')).toBe(true); + }); + + it('persists sold seats across a save/reload cycle, surviving a fresh selection', async () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().confirmPurchase(7000); + + await Promise.resolve(); + + const raw = getPersistedRaw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = JSON.parse(raw) as unknown as { state: { soldSeats: string[] } }; + expect(parsed.state.soldSeats).toEqual(['SEAT-1']); + + useVenueStore.setState({ soldSeats: new Set() }); + useVenueStore.setState({ soldSeats: new Set(parsed.state.soldSeats) }); + + expect(useVenueStore.getState().soldSeats.has('SEAT-1')).toBe(true); + }); + + it('persists booking history across a save/reload cycle', async () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + useVenueStore.getState().confirmPurchase(7000); + + await Promise.resolve(); + + const raw = getPersistedRaw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = JSON.parse(raw) as unknown as { + state: { bookings: { id: string; seatIds: string[]; total: number; createdAt: number }[] }; + }; + expect(parsed.state.bookings).toHaveLength(1); + expect(parsed.state.bookings[0]?.seatIds).toEqual(['SEAT-1']); + expect(parsed.state.bookings[0]?.total).toBe(7000); + + useVenueStore.setState({ bookings: [] }); + useVenueStore.setState({ bookings: parsed.state.bookings }); + + expect(useVenueStore.getState().bookings).toHaveLength(1); + }); + + it('persists the checkout deadline across a save/reload cycle', async () => { + useVenueStore.getState().toggleSeat('SEAT-1'); + const deadline = useVenueStore.getState().selectionExpiresAt; + + await Promise.resolve(); + + const raw = getPersistedRaw(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsed = JSON.parse(raw) as unknown as { state: { selectionExpiresAt: number | null } }; + expect(parsed.state.selectionExpiresAt).toBe(deadline); + + useVenueStore.setState({ selectionExpiresAt: null }); + useVenueStore.setState({ selectionExpiresAt: parsed.state.selectionExpiresAt }); + expect(useVenueStore.getState().selectionExpiresAt).toBe(deadline); + }); + + it('never writes holds to localStorage - they are session-local, not persisted state', async () => { + useVenueStore.setState({ holds: holdMap('SEAT-1', 9_000) }); + useVenueStore.getState().toggleSeat('SEAT-2'); + + await Promise.resolve(); + + const raw = getPersistedRaw(); + expect(raw).not.toContain('"holds"'); + }); +}); diff --git a/src/store/seatStore.ts b/src/store/seatStore.ts index a617483..1345a08 100644 --- a/src/store/seatStore.ts +++ b/src/store/seatStore.ts @@ -1,68 +1,317 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { PersistStorage, StorageValue } from 'zustand/middleware'; +import type { HoldMap } from '../interfaces/venue.interfaces'; +import { SELECTION_HOLD_MS, pruneHolds } from '../utils/holdProtocol'; + +export const MAX_SELECTABLE_SEATS = 8; + +export interface Feedback { + type: 'info' | 'error'; + message: string; +} + +export interface Booking { + id: string; + seatIds: string[]; + total: number; + createdAt: number; +} interface VenueState { - activeSectionId: string | null; - selectedSeats: Set; - zoom: number; - setActiveSection: (id: string | null) => void; - setZoom: (zoom: number) => void; - toggleSeat: (seatId: string) => void; - clearSelection: () => void; + activeSectionId: string | null; + selectedSeats: Set; + soldSeats: Set; + bookings: Booking[]; + viewingBookingId: string | null; + // null means "fit the whole section"; set by selectSeatBlock/viewBooking, cleared by setActiveSection. + mapFocusSeatIds: string[] | null; + zoom: number; + pan: { x: number; y: number }; + viewTransitionMs: number; + feedback: Feedback | null; + + // Only ever OTHER tabs' claims - this tab's own is selectedSeats + selectionExpiresAt. + holds: HoldMap; + selectionExpiresAt: number | null; + simulationSeeded: boolean; + + setActiveSection: (id: string | null) => void; + setPan: (pan: { x: number; y: number }) => void; + setView: (zoom: number, pan: { x: number; y: number }, transitionMs: number) => void; + toggleSeat: (seatId: string) => void; + selectSeatBlock: (seatIds: string[], sectionId: string) => void; + clearSelection: () => void; + confirmPurchase: (total: number) => Booking | null; + viewBooking: (id: string) => void; + clearViewingBooking: () => void; + clearFeedback: () => void; + setFeedback: (feedback: Feedback) => void; + + setHolds: (holds: HoldMap) => void; + seedSimulation: (holds: HoldMap) => void; + applyRemoteSold: (seatIds: string[]) => void; + releaseLostSeats: (seatIds: string[]) => void; + runExpiry: (now: number) => void; +} + +type PersistedVenueState = Pick< + VenueState, + 'selectedSeats' | 'soldSeats' | 'bookings' | 'selectionExpiresAt' +>; + +// Fields are optional: localStorage may hold data from an older app version. +interface SerializedVenueState { + selectedSeats?: string[]; + soldSeats?: string[]; + bookings?: Booking[]; + selectionExpiresAt?: number | null; +} + +function isPersistedStorageValue(data: unknown): data is StorageValue { + return ( + typeof data === 'object' && + data !== null && + 'state' in data && + typeof data.state === 'object' && + data.state !== null + ); } +const venueStorage: PersistStorage = { + getItem: (name) => { + const str = localStorage.getItem(name); + if (!str) return null; + const parsed: unknown = JSON.parse(str); + if (!isPersistedStorageValue(parsed)) return null; + const { state, version } = parsed; + return { + state: { + selectedSeats: new Set(state.selectedSeats ?? []), + soldSeats: new Set(state.soldSeats ?? []), + bookings: state.bookings ?? [], + selectionExpiresAt: + typeof state.selectionExpiresAt === 'number' ? state.selectionExpiresAt : null, + }, + ...(version !== undefined && { version }), + }; + }, + setItem: (name, value) => { + const serialized: StorageValue = { + state: { + selectedSeats: Array.from(value.state.selectedSeats), + soldSeats: Array.from(value.state.soldSeats), + bookings: value.state.bookings, + selectionExpiresAt: value.state.selectionExpiresAt, + }, + ...(value.version !== undefined && { version: value.version }), + }; + localStorage.setItem(name, JSON.stringify(serialized)); + }, + removeItem: (name) => { + localStorage.removeItem(name); + }, +}; + export const useVenueStore = create()( - persist( - (set) => ({ - activeSectionId: null, + persist( + (set) => ({ + activeSectionId: null, + selectedSeats: new Set(), + soldSeats: new Set(), + bookings: [], + viewingBookingId: null, + mapFocusSeatIds: null, + zoom: 0.4, + pan: { x: 0, y: 0 }, + viewTransitionMs: 200, + feedback: null, + holds: new Map(), + selectionExpiresAt: null, + simulationSeeded: false, + + setActiveSection: (id) => set({ activeSectionId: id, mapFocusSeatIds: null }), + setPan: (pan) => set({ pan }), + setView: (zoom, pan, transitionMs) => set({ zoom, pan, viewTransitionMs: transitionMs }), + + toggleSeat: (seatId) => + set((state) => { + const now = Date.now(); + const liveHold = state.holds.get(seatId); + if (liveHold && liveHold.expiresAt > now) { + return { feedback: { type: 'error', message: 'That seat was just taken.' } }; + } + + const newSelected = new Set(state.selectedSeats); + if (newSelected.has(seatId)) { + newSelected.delete(seatId); + } else { + if (newSelected.size >= MAX_SELECTABLE_SEATS) { + return { + feedback: { + type: 'error', + message: `You can select a maximum of ${MAX_SELECTABLE_SEATS} seats.`, + }, + }; + } + newSelected.add(seatId); + } + + return { + selectedSeats: newSelected, + viewingBookingId: null, + // Anchored to when the cart opened; adding seats doesn't extend it. + selectionExpiresAt: + newSelected.size === 0 ? null : (state.selectionExpiresAt ?? now + SELECTION_HOLD_MS), + }; + }), + + // One atomic transition, so there's no commit where the seats are + // selected but their section isn't mounted yet. + selectSeatBlock: (seatIds, sectionId) => + set(() => { + if (seatIds.length === 0 || seatIds.length > MAX_SELECTABLE_SEATS) { + return { + feedback: { + type: 'error', + message: `You can select a maximum of ${MAX_SELECTABLE_SEATS} seats.`, + }, + }; + } + return { + selectedSeats: new Set(seatIds), + activeSectionId: sectionId, + viewingBookingId: null, + mapFocusSeatIds: seatIds, + selectionExpiresAt: Date.now() + SELECTION_HOLD_MS, + }; + }), + + clearSelection: () => set({ selectedSeats: new Set(), selectionExpiresAt: null }), + + // Re-validates the deadline inside this same synchronous set() so it can't race the expiry timer. + confirmPurchase: (total) => { + let result: Booking | null = null; + set((state) => { + const now = Date.now(); + if (state.selectionExpiresAt !== null && state.selectionExpiresAt <= now) { + result = null; + return { + selectedSeats: new Set(), + selectionExpiresAt: null, + feedback: { + type: 'error', + message: + 'Your hold expired before payment completed. Please select your seats again.', + }, + }; + } + + const seatIds = Array.from(state.selectedSeats); + const newBooking: Booking = { + id: `BK-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + seatIds, + total, + createdAt: Date.now(), + }; + result = newBooking; + return { + soldSeats: new Set([...state.soldSeats, ...state.selectedSeats]), selectedSeats: new Set(), - zoom: 0.4, - setActiveSection: (id) => set({ activeSectionId: id }), - setZoom: (zoom) => set({ zoom }), - - toggleSeat: (seatId) => set((state) => { - const newSelected = new Set(state.selectedSeats); - if (newSelected.has(seatId)) { - newSelected.delete(seatId); - } else { - if (newSelected.size >= 8) { - alert("You can select a maximum of 8 seats."); - return state; - } - newSelected.add(seatId); - } - return { selectedSeats: newSelected }; - }), + selectionExpiresAt: null, + bookings: [newBooking, ...state.bookings], + viewingBookingId: newBooking.id, + }; + }); + return result; + }, - clearSelection: () => set({ selectedSeats: new Set() }) + viewBooking: (id) => + set((state) => { + const booking = state.bookings.find((b) => b.id === id); + return { viewingBookingId: id, mapFocusSeatIds: booking ? booking.seatIds : null }; }), - { - name: "venue-storage", - storage: { - getItem: (name) => { - const str = localStorage.getItem(name); - if (!str) return null; - const { state } = JSON.parse(str); - return { - state: { - ...state, - selectedSeats: new Set(state.selectedSeats || []), - }, - }; - }, - setItem: (name, value) => { - const { state } = value; - const serialized = { - state: { - ...state, - selectedSeats: Array.from(state.selectedSeats || []), - }, - }; - localStorage.setItem(name, JSON.stringify(serialized)); - }, - removeItem: (name) => localStorage.removeItem(name), + clearViewingBooking: () => set({ viewingBookingId: null, mapFocusSeatIds: null }), + + clearFeedback: () => set({ feedback: null }), + setFeedback: (feedback) => set({ feedback }), + + setHolds: (holds) => set({ holds }), + + // Guarded so a late cross-tab `state` reply can't clobber the seeding that already happened. + seedSimulation: (holds) => + set((state) => (state.simulationSeeded ? {} : { holds, simulationSeeded: true })), + + applyRemoteSold: (seatIds) => + set((state) => { + if (seatIds.length === 0) return {}; + const soldSet = new Set(state.soldSeats); + const nextHolds = new Map(state.holds); + const nextSelected = new Set(state.selectedSeats); + let selectionChanged = false; + for (const id of seatIds) { + soldSet.add(id); + nextHolds.delete(id); + if (nextSelected.delete(id)) selectionChanged = true; + } + return { + soldSeats: soldSet, + holds: nextHolds, + selectedSeats: nextSelected, + selectionExpiresAt: + selectionChanged && nextSelected.size === 0 ? null : state.selectionExpiresAt, + }; + }), + + releaseLostSeats: (seatIds) => + set((state) => { + if (seatIds.length === 0) return {}; + const next = new Set(state.selectedSeats); + seatIds.forEach((id) => next.delete(id)); + return { + selectedSeats: next, + selectionExpiresAt: next.size === 0 ? null : state.selectionExpiresAt, + feedback: { + type: 'error', + message: + seatIds.length === 1 + ? 'A seat you selected was taken in another tab.' + : 'Some seats you selected were taken in another tab.', }, - partialize: (state) => ({ selectedSeats: state.selectedSeats }), - } - ) + }; + }), + + // Skips the set() entirely when nothing changed, so a no-op tick costs no re-render. + runExpiry: (now) => + set((state) => { + const prunedHolds = pruneHolds(state.holds, now); + const selectionExpired = + state.selectionExpiresAt !== null && state.selectionExpiresAt <= now; + + if (prunedHolds === state.holds && !selectionExpired) return {}; + + return { + ...(prunedHolds !== state.holds && { holds: prunedHolds }), + ...(selectionExpired && { + selectedSeats: new Set(), + selectionExpiresAt: null, + feedback: { + type: 'info' as const, + message: 'Your seat hold expired. Please select your seats again.', + }, + }), + }; + }), + }), + { + name: 'venue-storage', + storage: venueStorage, + partialize: (state) => ({ + selectedSeats: state.selectedSeats, + soldSeats: state.soldSeats, + bookings: state.bookings, + selectionExpiresAt: state.selectionExpiresAt, + }), + }, + ), ); diff --git a/src/test/fakeBroadcastChannel.ts b/src/test/fakeBroadcastChannel.ts new file mode 100644 index 0000000..7c5206b --- /dev/null +++ b/src/test/fakeBroadcastChannel.ts @@ -0,0 +1,42 @@ +import type { BroadcastChannelLike } from '../utils/holdChannel'; + +// Synchronous BroadcastChannel stand-in (jsdom has no real one): every +// channel receives every other channel's postMessage, never its own. +export function createFakeBus() { + const channels = new Set<{ + listeners: Set<(event: { data: unknown }) => void>; + post: (message: unknown) => void; + }>(); + + const factory = (): BroadcastChannelLike => { + const listeners = new Set<(event: { data: unknown }) => void>(); + const self = { + listeners, + post: (message: unknown) => { + listeners.forEach((listener) => { + listener({ data: message }); + }); + }, + }; + channels.add(self); + + return { + postMessage: (message) => { + channels.forEach((channel) => { + if (channel !== self) channel.post(message); + }); + }, + close: () => { + channels.delete(self); + }, + addEventListener: (_type, listener) => { + listeners.add(listener); + }, + removeEventListener: (_type, listener) => { + listeners.delete(listener); + }, + }; + }; + + return { factory, channels }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..01d18c2 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +afterEach(() => { + cleanup(); +}); diff --git a/src/utils/findBestSeats.test.ts b/src/utils/findBestSeats.test.ts new file mode 100644 index 0000000..ca0f13c --- /dev/null +++ b/src/utils/findBestSeats.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; +import type { ISeat, Row, Section, Venue } from '../interfaces/venue.interfaces'; +import { TIERS } from './seatIndex'; +import { compareBlocks, findBestSeats } from './findBestSeats'; +import type { BlockRanking } from './findBestSeats'; + +function makeSeat(col: number, priceTier = 1, status: ISeat['status'] = 'available'): ISeat { + return { id: `S${col}`, col, x: col, y: 0, priceTier, status }; +} + +function makeRow(index: number, seats: ISeat[]): Row { + return { index, seats }; +} + +function makeVenue(sections: { id: string; label: string; rows: Row[] }[]): Venue { + const mapped: Section[] = sections.map((s) => ({ + id: s.id, + label: s.label, + transform: { x: 0, y: 0, scale: 1 }, + rows: s.rows, + })); + return { venueId: 'v', name: 'Venue', map: { width: 100, height: 100 }, sections: mapped }; +} + +const isAvailable = (seat: ISeat) => seat.status === 'available'; + +describe('findBestSeats', () => { + it('finds the only run of exactly partySize seats, in ascending col order', () => { + const venue = makeVenue([ + { + id: 'S-1', + label: 'Section 1', + rows: [ + makeRow( + 1, + [1, 2, 3].map((c) => makeSeat(c)), + ), + ], + }, + ]); + const block = findBestSeats(venue, 3, 'view', isAvailable); + expect(block?.seatIds).toEqual(['S1', 'S2', 'S3']); + }); + + it('returns null when no run is long enough', () => { + const venue = makeVenue([ + { id: 'S-1', label: 'Section 1', rows: [makeRow(1, [makeSeat(1), makeSeat(2)])] }, + ]); + expect(findBestSeats(venue, 3, 'view', isAvailable)).toBeNull(); + }); + + it.each(['sold', 'reserved', 'held'] as const)( + 'treats %s seats as unselectable via the injected predicate', + (status) => { + const seats = [makeSeat(1), makeSeat(2, 1, status), makeSeat(3), makeSeat(4)]; + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + const block = findBestSeats(venue, 2, 'view', isAvailable); + expect(block?.seatIds).toEqual(['S3', 'S4']); + }, + ); + + it('respects the injected predicate for seats venue.json itself marks available', () => { + const seats = [makeSeat(1), makeSeat(2), makeSeat(3), makeSeat(4)]; + const soldElsewhere = new Set(['S2']); + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + const block = findBestSeats( + venue, + 2, + 'view', + (seat) => isAvailable(seat) && !soldElsewhere.has(seat.id), + ); + expect(block?.seatIds).toEqual(['S3', 'S4']); + }); + + it('a gap in col breaks contiguity - refuses to span it', () => { + const seats = [makeSeat(1), makeSeat(2), makeSeat(4), makeSeat(5)]; + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + expect(findBestSeats(venue, 3, 'view', isAvailable)).toBeNull(); + }); + + it('the same gapped row still finds the smaller in-bounds block', () => { + const seats = [makeSeat(1), makeSeat(2), makeSeat(4), makeSeat(5)]; + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + const block = findBestSeats(venue, 2, 'view', isAvailable); + expect(block?.seatIds).toEqual(['S1', 'S2']); + }); + + it("'view' prefers the lower row index even when a further row is cheaper and better centred", () => { + const venue = makeVenue([ + { + id: 'S-1', + label: 'Section 1', + rows: [ + makeRow( + 1, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 3)), + ), + makeRow( + 2, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 1)), + ), + ], + }, + ]); + const block = findBestSeats(venue, 2, 'view', isAvailable); + expect(block?.rowIndex).toBe(1); + }); + + it("'price' prefers the cheaper block even when it is further from the stage", () => { + const venue = makeVenue([ + { + id: 'S-1', + label: 'Section 1', + rows: [ + makeRow( + 1, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 1)), + ), + makeRow( + 2, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 3)), + ), + ], + }, + ]); + const block = findBestSeats(venue, 2, 'price', isAvailable); + expect(block?.rowIndex).toBe(2); + expect(block?.totalPrice).toBe(TIERS[3]?.price ? TIERS[3].price * 2 : 0); + }); + + it("'price' tiebreak: among equally-priced blocks, the lower row index wins", () => { + const venue = makeVenue([ + { + id: 'S-1', + label: 'Section 1', + rows: [ + makeRow( + 1, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 2)), + ), + makeRow( + 2, + [1, 2, 3, 4, 5].map((c) => makeSeat(c, 2)), + ), + ], + }, + ]); + const block = findBestSeats(venue, 2, 'price', isAvailable); + expect(block?.rowIndex).toBe(1); + }); + + it("'view' tiebreak within one row: the most centred window wins, not the leftmost", () => { + const seats = Array.from({ length: 18 }, (_, i) => makeSeat(i + 1)); + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + const block = findBestSeats(venue, 4, 'view', isAvailable); + // Row centre is 9.5; cols 8-11 are centred, not the leftmost window. + expect(block?.seatIds).toEqual(['S8', 'S9', 'S10', 'S11']); + }); + + it('centring is measured in col space: identical col layouts rank identically regardless of x/y spacing', () => { + const tightRow = makeRow( + 1, + [1, 2, 3, 4, 5].map((c) => ({ ...makeSeat(c), x: c * 3.77 })), + ); + const wideRow = makeRow( + 2, + [1, 2, 3, 4, 5].map((c) => ({ ...makeSeat(c), x: c * 8.14 })), + ); + const venueTight = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [tightRow] }]); + const venueWide = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [wideRow] }]); + const tightBlock = findBestSeats(venueTight, 3, 'view', isAvailable); + const wideBlock = findBestSeats(venueWide, 3, 'view', isAvailable); + expect(tightBlock?.centreOffset).toBe(wideBlock?.centreOffset); + }); + + it('determinism across congruent sections: the lower sectionOrder wins regardless of input order', () => { + const row = () => + makeRow( + 1, + [1, 2, 3].map((c) => makeSeat(c)), + ); + const forward = makeVenue([ + { id: 'S-A', label: 'A', rows: [row()] }, + { id: 'S-B', label: 'B', rows: [row()] }, + ]); + const reversed = makeVenue([ + { id: 'S-B', label: 'B', rows: [row()] }, + { id: 'S-A', label: 'A', rows: [row()] }, + ]); + expect(findBestSeats(forward, 3, 'view', isAvailable)?.sectionId).toBe('S-A'); + expect(findBestSeats(reversed, 3, 'view', isAvailable)?.sectionId).toBe('S-B'); + }); + + it('party size 1 returns a single seat with startCol === endCol', () => { + const venue = makeVenue([ + { id: 'S-1', label: 'Section 1', rows: [makeRow(1, [makeSeat(1), makeSeat(2)])] }, + ]); + const block = findBestSeats(venue, 1, 'view', isAvailable); + expect(block?.startCol).toBe(block?.endCol); + expect(block?.seatIds).toHaveLength(1); + }); + + it.each([0, -1, 2.5])('rejects an invalid party size (%s) rather than throwing', (partySize) => { + const venue = makeVenue([ + { id: 'S-1', label: 'Section 1', rows: [makeRow(1, [makeSeat(1), makeSeat(2)])] }, + ]); + expect(findBestSeats(venue, partySize, 'view', isAvailable)).toBeNull(); + }); + + it('an empty venue, a row-less section, and a seat-less row all return null without throwing', () => { + const empty = makeVenue([]); + expect(findBestSeats(empty, 2, 'view', isAvailable)).toBeNull(); + + const noRows = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [] }]); + expect(findBestSeats(noRows, 2, 'view', isAvailable)).toBeNull(); + + const noSeats = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, [])] }]); + expect(findBestSeats(noSeats, 2, 'view', isAvailable)).toBeNull(); + }); + + it('sums real per-seat prices rather than assuming a uniform tier across the block', () => { + const seats = [makeSeat(1, 1), makeSeat(2, 1), makeSeat(3, 3)]; + const venue = makeVenue([{ id: 'S-1', label: 'Section 1', rows: [makeRow(1, seats)] }]); + const block = findBestSeats(venue, 3, 'view', isAvailable); + const tier1 = TIERS[1]?.price ?? 0; + const tier3 = TIERS[3]?.price ?? 0; + expect(block?.totalPrice).toBe(tier1 * 2 + tier3); + }); + + it('skips a row shorter than partySize and still finds a later valid row', () => { + const venue = makeVenue([ + { + id: 'S-1', + label: 'Section 1', + rows: [makeRow(1, [makeSeat(1)]), makeRow(2, [makeSeat(1), makeSeat(2), makeSeat(3)])], + }, + ]); + const block = findBestSeats(venue, 3, 'view', isAvailable); + expect(block?.rowIndex).toBe(2); + }); +}); + +describe('compareBlocks', () => { + const base: BlockRanking = { + rowIndex: 1, + totalPrice: 100, + centreOffset: 0, + sectionOrder: 0, + startCol: 1, + }; + + it('is antisymmetric and zero only for identical rankings, for both priorities', () => { + const variants: BlockRanking[] = [ + base, + { ...base, rowIndex: 2 }, + { ...base, totalPrice: 50 }, + { ...base, centreOffset: 3 }, + { ...base, sectionOrder: 1 }, + { ...base, startCol: 5 }, + ]; + + // Summing signs (not negating) avoids +0 vs -0 mismatching under `.toBe`. + (['view', 'price'] as const).forEach((priority) => { + for (const a of variants) { + for (const b of variants) { + const cmp = compareBlocks(a, b, priority); + const rev = compareBlocks(b, a, priority); + expect(Math.sign(cmp) + Math.sign(rev)).toBe(0); + if (a === b) expect(cmp).toBe(0); + } + } + }); + }); +}); diff --git a/src/utils/findBestSeats.ts b/src/utils/findBestSeats.ts new file mode 100644 index 0000000..02f7496 --- /dev/null +++ b/src/utils/findBestSeats.ts @@ -0,0 +1,139 @@ +import type { ISeat, Venue } from '../interfaces/venue.interfaces'; +import { getTier } from './seatIndex'; +import { rowLetter } from './rowLetter'; + +export type SeatPriority = 'view' | 'price'; + +export type IsSeatSelectable = (seat: ISeat) => boolean; + +export interface BlockRanking { + rowIndex: number; + totalPrice: number; + // Col-space, not x/y: col maps linearly onto arc angle, so this is angular centring. + centreOffset: number; + sectionOrder: number; + startCol: number; +} + +export interface SeatBlock extends BlockRanking { + seatIds: string[]; + sectionId: string; + sectionLabel: string; + rowLabel: string; + endCol: number; +} + +// (sectionOrder, rowIndex, startCol) is unique per candidate, so this is a strict total order. +const RANKING_KEYS: Record = { + view: ['rowIndex', 'centreOffset', 'totalPrice', 'sectionOrder', 'startCol'], + price: ['totalPrice', 'rowIndex', 'centreOffset', 'sectionOrder', 'startCol'], +}; + +export function compareBlocks(a: BlockRanking, b: BlockRanking, priority: SeatPriority): number { + for (const key of RANKING_KEYS[priority]) { + const diff = a[key] - b[key]; + if (diff !== 0) return diff; + } + return 0; +} + +interface BestSoFar { + ranking: BlockRanking; + sectionId: string; + sectionLabel: string; + rowIndex: number; + seats: ISeat[]; +} + +function scoreRow( + row: { index: number; seats: ISeat[] }, + section: { id: string; label: string }, + sectionOrder: number, + partySize: number, + priority: SeatPriority, + isSelectable: IsSeatSelectable, + best: BestSoFar | null, +): BestSoFar | null { + const [first] = row.seats; + const last = row.seats.at(-1); + if (!first || !last) return best; + const rowCentre = (first.col + last.col) / 2; + + let prev: ISeat | undefined; + let window: ISeat[] = []; + let updated = best; + + for (const seat of row.seats) { + const selectable = isSelectable(seat); + const brokenRun = !selectable || (prev !== undefined && seat.col !== prev.col + 1); + if (brokenRun) window = []; + if (selectable) { + window.push(seat); + if (window.length > partySize) window.shift(); + + if (window.length === partySize) { + const [windowFirst] = window; + const windowLast = window.at(-1); + if (windowFirst && windowLast) { + let totalPrice = 0; + for (const s of window) totalPrice += getTier(s.priceTier).price; + const blockCentre = (windowFirst.col + windowLast.col) / 2; + + const ranking: BlockRanking = { + rowIndex: row.index, + totalPrice, + centreOffset: Math.abs(blockCentre - rowCentre), + sectionOrder, + startCol: windowFirst.col, + }; + + if (!updated || compareBlocks(ranking, updated.ranking, priority) < 0) { + updated = { + ranking, + sectionId: section.id, + sectionLabel: section.label, + rowIndex: row.index, + seats: [...window], + }; + } + } + } + } + prev = seat; + } + + return updated; +} + +export function findBestSeats( + venue: Venue, + partySize: number, + priority: SeatPriority, + isSelectable: IsSeatSelectable, +): SeatBlock | null { + if (!Number.isInteger(partySize) || partySize < 1) return null; + + let best: BestSoFar | null = null; + let sectionOrder = 0; + for (const section of venue.sections) { + for (const row of section.rows) { + best = scoreRow(row, section, sectionOrder, partySize, priority, isSelectable, best); + } + sectionOrder += 1; + } + + if (!best) return null; + const { ranking, sectionId, sectionLabel, rowIndex, seats } = best; + const [firstSeat] = seats; + const lastSeat = seats.at(-1); + if (!firstSeat || !lastSeat) return null; + + return { + ...ranking, + seatIds: seats.map((s) => s.id), + sectionId, + sectionLabel, + rowLabel: rowLetter(rowIndex - 1), + endCol: lastSeat.col, + }; +} diff --git a/src/utils/formatCountdown.test.ts b/src/utils/formatCountdown.test.ts new file mode 100644 index 0000000..0cefe45 --- /dev/null +++ b/src/utils/formatCountdown.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + describeCountdownMilestone, + formatCountdown, + pickCountdownMilestone, +} from './formatCountdown'; + +describe('formatCountdown', () => { + it.each([ + [300_000, '5:00'], + [65_000, '1:05'], + [5_000, '0:05'], + [999, '0:01'], // ceil, not floor + [0, '0:00'], + [-5_000, '0:00'], + [3_600_000, '60:00'], + ])('formatCountdown(%i) -> %s', (ms, expected) => { + expect(formatCountdown(ms)).toBe(expected); + }); +}); + +describe('pickCountdownMilestone', () => { + it.each([ + [301_000, 'start'], + [300_000, 'start'], + [121_000, 'start'], + [120_000, '2min'], + [61_000, '2min'], + [60_000, '1min'], + [31_000, '1min'], + [30_000, '30s'], + [11_000, '30s'], + [10_000, '10s'], + [1_000, '10s'], + [0, 'expired'], + ])('pickCountdownMilestone(%i) -> %s', (ms, expected) => { + expect(pickCountdownMilestone(ms)).toBe(expected); + }); + + it('is stable across an entire band - the property that keeps aria-live quiet', () => { + expect(pickCountdownMilestone(119_000)).toBe(pickCountdownMilestone(61_000)); + }); + + it('changes when crossing the 2-minute boundary', () => { + expect(pickCountdownMilestone(121_000)).not.toBe(pickCountdownMilestone(119_000)); + }); +}); + +describe('describeCountdownMilestone', () => { + it('has distinct, non-empty copy for every milestone', () => { + const milestones = ['start', '2min', '1min', '30s', '10s', 'expired'] as const; + const descriptions = milestones.map(describeCountdownMilestone); + expect(new Set(descriptions).size).toBe(milestones.length); + descriptions.forEach((d) => { + expect(d.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/utils/formatCountdown.ts b/src/utils/formatCountdown.ts new file mode 100644 index 0000000..d7164df --- /dev/null +++ b/src/utils/formatCountdown.ts @@ -0,0 +1,36 @@ +export function formatCountdown(remainingMs: number): string { + const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +export type CountdownMilestone = 'start' | '2min' | '1min' | '30s' | '10s' | 'expired'; + +// Bands (not exact seconds) so an aria-live announcement doesn't fire every tick. +export function pickCountdownMilestone(remainingMs: number): CountdownMilestone { + const totalSeconds = Math.ceil(remainingMs / 1000); + if (totalSeconds <= 0) return 'expired'; + if (totalSeconds <= 10) return '10s'; + if (totalSeconds <= 30) return '30s'; + if (totalSeconds <= 60) return '1min'; + if (totalSeconds <= 120) return '2min'; + return 'start'; +} + +export function describeCountdownMilestone(milestone: CountdownMilestone): string { + switch (milestone) { + case 'start': + return 'Your seats are held for 5 minutes to complete checkout.'; + case '2min': + return '2 minutes left to complete your booking.'; + case '1min': + return '1 minute left to complete your booking.'; + case '30s': + return '30 seconds left to complete your booking.'; + case '10s': + return '10 seconds left to complete your booking.'; + case 'expired': + return 'Your seat hold expired.'; + } +} diff --git a/src/utils/holdChannel.realTransport.test.ts b/src/utils/holdChannel.realTransport.test.ts new file mode 100644 index 0000000..d335bd3 --- /dev/null +++ b/src/utils/holdChannel.realTransport.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; +import type { HoldMessage } from '../interfaces/venue.interfaces'; +import { createHoldChannel } from './holdChannel'; +import { resolveSeatStatus } from './seatStatus'; + +describe('cross-tab hold delivery over the real BroadcastChannel', () => { + const openChannels: { close: () => void }[] = []; + + afterEach(() => { + openChannels.splice(0).forEach((c) => { + c.close(); + }); + }); + + it('is actually available in this environment (sanity check for the rest of the file)', () => { + const tab = createHoldChannel('sanity', () => undefined); + openChannels.push(tab); + expect(tab.available).toBe(true); + }); + + it('a hold posted by one tab is received by another, live, over the real transport', async () => { + const receivedByB: HoldMessage[] = []; + const tabB = createHoldChannel('tab-b', (msg) => { + receivedByB.push(msg); + }); + const tabA = createHoldChannel('tab-a', () => undefined); + openChannels.push(tabA, tabB); + + const expiresAt = Date.now() + 5 * 60_000; + tabA.post({ + type: 'hold', + from: 'tab-a', + sentAt: Date.now(), + seatIds: ['SEAT-1'], + expiresAt, + }); + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + expect(receivedByB).toHaveLength(1); + const message = receivedByB[0]; + if (message?.type !== 'hold') throw new Error('Expected exactly one hold message.'); + expect(message.from).toBe('tab-a'); + expect(message.seatIds).toEqual(['SEAT-1']); + expect(message.expiresAt).toBe(expiresAt); + + const holds = new Map([['SEAT-1', { seatId: 'SEAT-1', owner: 'tab-a', expiresAt }]]); + const status = resolveSeatStatus( + { id: 'SEAT-1', status: 'available' }, + { + soldSeats: new Set(), + holds, + selectedSeats: new Set(), + now: Date.now(), + simulationSeeded: true, + }, + ); + expect(status).toBe('held'); + }); + + it('a tab never receives its own broadcast back', async () => { + const receivedByA: HoldMessage[] = []; + const tabA = createHoldChannel('tab-a', (msg) => { + receivedByA.push(msg); + }); + openChannels.push(tabA); + + tabA.post({ + type: 'hold', + from: 'tab-a', + sentAt: Date.now(), + seatIds: ['SEAT-1'], + expiresAt: 0, + }); + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + expect(receivedByA).toHaveLength(0); + }); + + it('once released (an empty hold list), the seat is selectable again for the other tab', async () => { + const receivedByB: HoldMessage[] = []; + const tabB = createHoldChannel('tab-b', (msg) => { + receivedByB.push(msg); + }); + const tabA = createHoldChannel('tab-a', () => undefined); + openChannels.push(tabA, tabB); + + tabA.post({ type: 'hold', from: 'tab-a', sentAt: Date.now(), seatIds: [], expiresAt: 0 }); + + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + + expect(receivedByB).toHaveLength(1); + const message = receivedByB[0]; + if (message?.type !== 'hold') throw new Error('Expected exactly one hold message.'); + expect(message.seatIds).toEqual([]); + + const status = resolveSeatStatus( + { id: 'SEAT-1', status: 'available' }, + { + soldSeats: new Set(), + holds: new Map(), + selectedSeats: new Set(), + now: Date.now(), + simulationSeeded: true, + }, + ); + expect(status).toBe('available'); + }); +}); diff --git a/src/utils/holdChannel.test.ts b/src/utils/holdChannel.test.ts new file mode 100644 index 0000000..d11fc92 --- /dev/null +++ b/src/utils/holdChannel.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { HoldMessage } from '../interfaces/venue.interfaces'; +import { createFakeBus } from '../test/fakeBroadcastChannel'; +import { createHoldChannel } from './holdChannel'; + +function helloMessage(from: string) { + return { type: 'hello' as const, from, sentAt: 0, ownedSeatIds: [] }; +} + +describe('createHoldChannel', () => { + it('delivers a message from A to B but not back to A', () => { + const { factory } = createFakeBus(); + const onA = vi.fn(); + const onB = vi.fn(); + const a = createHoldChannel('a', onA, factory); + createHoldChannel('b', onB, factory); + + a.post(helloMessage('a')); + + expect(onB).toHaveBeenCalledTimes(1); + expect(onA).not.toHaveBeenCalled(); + }); + + it('drops a message whose `from` matches this channel’s own session id', () => { + const { factory } = createFakeBus(); + const onB = vi.fn(); + createHoldChannel('a', vi.fn(), factory); + const b = createHoldChannel('b', onB, factory); + b.post(helloMessage('b')); + expect(onB).not.toHaveBeenCalled(); + }); + + it('returns a no-op, unavailable channel when the factory returns null', () => { + const onMessage = vi.fn(); + const channel = createHoldChannel('a', onMessage, () => null); + + expect(channel.available).toBe(false); + expect(() => { + channel.post(helloMessage('a')); + }).not.toThrow(); + expect(() => { + channel.close(); + }).not.toThrow(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('returns a no-op channel when the factory throws (e.g. a restrictive privacy mode)', () => { + const channel = createHoldChannel('a', vi.fn(), () => { + throw new Error('BroadcastChannel is not allowed here'); + }); + expect(channel.available).toBe(false); + }); + + it('drops malformed payloads without invoking onMessage', () => { + const { factory } = createFakeBus(); + const onB = vi.fn(); + const a = createHoldChannel('a', vi.fn(), factory); + createHoldChannel('b', onB, factory); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + a.post({ type: 'bogus' } as unknown as HoldMessage); + + expect(onB).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/holdChannel.ts b/src/utils/holdChannel.ts new file mode 100644 index 0000000..7cdd735 --- /dev/null +++ b/src/utils/holdChannel.ts @@ -0,0 +1,57 @@ +import type { HoldMessage } from '../interfaces/venue.interfaces'; +import { HOLD_CHANNEL_NAME, isHoldMessage } from './holdProtocol'; + +// Minimal surface used from BroadcastChannel, so tests can supply a fake (jsdom has none). +export interface BroadcastChannelLike { + postMessage: (message: unknown) => void; + close: () => void; + addEventListener: (type: 'message', listener: (event: { data: unknown }) => void) => void; + removeEventListener: (type: 'message', listener: (event: { data: unknown }) => void) => void; +} + +export interface HoldChannel { + post: (message: HoldMessage) => void; + close: () => void; + readonly available: boolean; +} + +function defaultFactory(name: string): BroadcastChannelLike | null { + if (typeof BroadcastChannel === 'undefined') return null; + return new BroadcastChannel(name); +} + +export function createHoldChannel( + sessionId: string, + onMessage: (message: HoldMessage) => void, + factory: (name: string) => BroadcastChannelLike | null = defaultFactory, +): HoldChannel { + let channel: BroadcastChannelLike | null; + try { + channel = factory(HOLD_CHANNEL_NAME); + } catch { + channel = null; + } + + if (!channel) { + return { post: () => undefined, close: () => undefined, available: false }; + } + + const activeChannel = channel; + const listener = (event: { data: unknown }) => { + if (!isHoldMessage(event.data)) return; + if (event.data.from === sessionId) return; + onMessage(event.data); + }; + activeChannel.addEventListener('message', listener); + + return { + post: (message) => { + activeChannel.postMessage(message); + }, + close: () => { + activeChannel.removeEventListener('message', listener); + activeChannel.close(); + }, + available: true, + }; +} diff --git a/src/utils/holdProtocol.test.ts b/src/utils/holdProtocol.test.ts new file mode 100644 index 0000000..246c59b --- /dev/null +++ b/src/utils/holdProtocol.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import type { HoldMap, SeatHold, WireHold } from '../interfaces/venue.interfaces'; +import { + CLOCK_SKEW_TOLERANCE_MS, + adoptRemoteState, + ingestPeerHold, + isHoldMessage, + nextDeadline, + pruneHolds, + toWireHolds, + translateExpiry, +} from './holdProtocol'; + +function holdMap(...holds: SeatHold[]): HoldMap { + return new Map(holds.map((h) => [h.seatId, h])); +} + +describe('isHoldMessage', () => { + it.each([ + { type: 'hello', from: 'a', sentAt: 1, ownedSeatIds: ['S1'] }, + { type: 'state', from: 'a', sentAt: 1, to: 'b', holds: [] }, + { type: 'hold', from: 'a', sentAt: 1, seatIds: ['S1'], expiresAt: 2 }, + { type: 'sold', from: 'a', sentAt: 1, seatIds: ['S1'] }, + { type: 'goodbye', from: 'a', sentAt: 1 }, + ])('accepts a valid %o message', (message) => { + expect(isHoldMessage(message)).toBe(true); + }); + + it.each([ + null, + 'a string', + 42, + {}, + { type: 'hello' }, // missing from/sentAt + { type: 'bogus', from: 'a', sentAt: 1 }, + { type: 'hold', from: 'a', sentAt: 1, seatIds: 'not-an-array', expiresAt: 1 }, + { type: 'hold', from: 'a', sentAt: 1, seatIds: [1, 2], expiresAt: 1 }, + { type: 'hold', from: 'a', sentAt: 1, seatIds: [], expiresAt: 'not-a-number' }, + { type: 'sold', from: 'a', sentAt: 1 }, // missing seatIds + ])('rejects %o', (payload) => { + expect(isHoldMessage(payload)).toBe(false); + }); +}); + +describe('translateExpiry', () => { + it('leaves the deadline untouched when skew is within tolerance', () => { + const sentAt = 1_000; + const receivedAt = sentAt + CLOCK_SKEW_TOLERANCE_MS; + expect(translateExpiry(5_000, sentAt, receivedAt)).toBe(5_000); + }); + + it('shifts the deadline forward when the receiver clock is ahead beyond tolerance', () => { + const sentAt = 1_000; + const receivedAt = sentAt + CLOCK_SKEW_TOLERANCE_MS + 1; + expect(translateExpiry(5_000, sentAt, receivedAt)).toBe(5_000 + (CLOCK_SKEW_TOLERANCE_MS + 1)); + }); + + it('shifts the deadline backward when the receiver clock is behind beyond tolerance', () => { + const sentAt = 10_000; + const receivedAt = sentAt - CLOCK_SKEW_TOLERANCE_MS - 1; + expect(translateExpiry(5_000, sentAt, receivedAt)).toBe(5_000 - (CLOCK_SKEW_TOLERANCE_MS + 1)); + }); +}); + +describe('pruneHolds', () => { + it('returns the identical map reference when nothing has lapsed', () => { + const holds = holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 10_000 }); + expect(pruneHolds(holds, 5_000)).toBe(holds); + }); + + it('drops only the holds that have lapsed', () => { + const holds = holdMap( + { seatId: 'S1', owner: 'peer', expiresAt: 1_000 }, + { seatId: 'S2', owner: 'peer', expiresAt: 9_000 }, + ); + const pruned = pruneHolds(holds, 5_000); + expect(Array.from(pruned.keys())).toEqual(['S2']); + }); + + it('treats expiresAt === now as lapsed (strict > for "still held")', () => { + const holds = holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 5_000 }); + expect(pruneHolds(holds, 5_000).size).toBe(0); + }); + + it('prunes hundreds of lapsed holds in a single pass (the wake-from-background case)', () => { + const many = Array.from({ length: 500 }, (_, i) => ({ + seatId: `S${i}`, + owner: 'peer', + expiresAt: 1_000, + })); + const holds = holdMap(...many); + expect(pruneHolds(holds, 5_000).size).toBe(0); + }); +}); + +describe('nextDeadline', () => { + it('is null when there is nothing to schedule', () => { + expect(nextDeadline(holdMap(), null)).toBeNull(); + }); + + it('is the minimum across all holds', () => { + const holds = holdMap( + { seatId: 'S1', owner: 'peer', expiresAt: 9_000 }, + { seatId: 'S2', owner: 'peer', expiresAt: 3_000 }, + ); + expect(nextDeadline(holds, null)).toBe(3_000); + }); + + it('is the minimum of holds and the local selection deadline', () => { + const holds = holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 9_000 }); + expect(nextDeadline(holds, 2_000)).toBe(2_000); + }); +}); + +describe('ingestPeerHold', () => { + it('inserts a non-conflicting peer hold', () => { + const result = ingestPeerHold(holdMap(), new Set(), 'me', { + from: 'peer', + seatIds: ['S1'], + expiresAt: 9_000, + }); + expect(result.holds.get('S1')).toEqual({ seatId: 'S1', owner: 'peer', expiresAt: 9_000 }); + expect(result.lostSeatIds).toEqual([]); + expect(result.mustRepost).toBe(false); + }); + + it('replaces the full set of a peer’s previous holds rather than merging them', () => { + const existing = holdMap( + { seatId: 'S1', owner: 'peer', expiresAt: 1_000 }, + { seatId: 'S2', owner: 'peer', expiresAt: 1_000 }, + ); + const result = ingestPeerHold(existing, new Set(), 'me', { + from: 'peer', + seatIds: ['S2', 'S3'], + expiresAt: 9_000, + }); + expect(Array.from(result.holds.keys()).sort()).toEqual(['S2', 'S3']); + }); + + it('never touches another owner’s holds', () => { + const existing = holdMap({ seatId: 'S1', owner: 'other-peer', expiresAt: 1_000 }); + const result = ingestPeerHold(existing, new Set(), 'me', { + from: 'peer', + seatIds: [], + expiresAt: 9_000, + }); + expect(result.holds.get('S1')?.owner).toBe('other-peer'); + }); + + it('a goodbye (empty seatIds) clears exactly that owner’s holds', () => { + const existing = holdMap( + { seatId: 'S1', owner: 'peer', expiresAt: 1_000 }, + { seatId: 'S2', owner: 'other-peer', expiresAt: 1_000 }, + ); + const result = ingestPeerHold(existing, new Set(), 'me', { + from: 'peer', + seatIds: [], + expiresAt: 0, + }); + expect(Array.from(result.holds.keys())).toEqual(['S2']); + }); + + it('refuses a message that claims to be from myself', () => { + const result = ingestPeerHold(holdMap(), new Set(), 'me', { + from: 'me', + seatIds: ['S1'], + expiresAt: 9_000, + }); + expect(result.holds.size).toBe(0); + }); + + it('conflict: a lexicographically lower peer id wins - I lose the seat', () => { + const result = ingestPeerHold(holdMap(), new Set(['S1']), 'zzz', { + from: 'aaa', + seatIds: ['S1'], + expiresAt: 9_000, + }); + expect(result.lostSeatIds).toEqual(['S1']); + expect(result.holds.get('S1')?.owner).toBe('aaa'); + expect(result.mustRepost).toBe(false); + }); + + it('conflict: a lexicographically higher peer id loses - I keep the seat and must repost', () => { + const result = ingestPeerHold(holdMap(), new Set(['S1']), 'aaa', { + from: 'zzz', + seatIds: ['S1'], + expiresAt: 9_000, + }); + expect(result.lostSeatIds).toEqual([]); + expect(result.holds.has('S1')).toBe(false); + expect(result.mustRepost).toBe(true); + }); +}); + +describe('adoptRemoteState', () => { + it('adopts every hold in the snapshot as authoritative, without a tie-break', () => { + const snapshot: WireHold[] = [{ seatId: 'S1', owner: 'incumbent', expiresAt: 9_000 }]; + const result = adoptRemoteState(new Set(['S1']), 'aaa', snapshot); + expect(result.holds.get('S1')?.owner).toBe('incumbent'); + expect(result.lostSeatIds).toEqual(['S1']); + }); + + it('ignores any snapshot entry attributed to me', () => { + const snapshot: WireHold[] = [{ seatId: 'S1', owner: 'me', expiresAt: 9_000 }]; + const result = adoptRemoteState(new Set(), 'me', snapshot); + expect(result.holds.size).toBe(0); + }); +}); + +describe('toWireHolds', () => { + it('round-trips through adoptRemoteState', () => { + const original = holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 9_000 }); + const { holds } = adoptRemoteState(new Set(), 'me', toWireHolds(original)); + expect(holds.get('S1')).toEqual(original.get('S1')); + }); +}); diff --git a/src/utils/holdProtocol.ts b/src/utils/holdProtocol.ts new file mode 100644 index 0000000..b673383 --- /dev/null +++ b/src/utils/holdProtocol.ts @@ -0,0 +1,162 @@ +import type { HoldMap, HoldMessage, SeatHold, WireHold } from '../interfaces/venue.interfaces'; + +export const SELECTION_HOLD_MS = 5 * 60_000; +export const SIM_WINDOW_MS = 6 * 60_000; +// Quantized so hundreds of holds collapse into a handful of release batches. +export const RELEASE_TICK_MS = 4_000; +export const EXPIRY_SLACK_MS = 50; +export const SYNC_TIMEOUT_MS = 250; +export const CLOCK_SKEW_TOLERANCE_MS = 2_000; +// Versioned so a protocol change can't cross-talk with an old, unreloaded tab. +export const HOLD_CHANNEL_NAME = 'venue-holds-v1'; + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string'); +} + +function isWireHold(value: unknown): value is WireHold { + return ( + typeof value === 'object' && + value !== null && + 'seatId' in value && + 'owner' in value && + 'expiresAt' in value && + typeof value.seatId === 'string' && + typeof value.owner === 'string' && + typeof value.expiresAt === 'number' + ); +} + +function isWireHoldArray(value: unknown): value is WireHold[] { + return Array.isArray(value) && value.every(isWireHold); +} + +export function isHoldMessage(data: unknown): data is HoldMessage { + if (typeof data !== 'object' || data === null) return false; + if (!('type' in data) || !('from' in data) || !('sentAt' in data)) return false; + if (typeof data.type !== 'string' || typeof data.from !== 'string') return false; + if (typeof data.sentAt !== 'number') return false; + + switch (data.type) { + case 'hello': + return 'ownedSeatIds' in data && isStringArray(data.ownedSeatIds); + case 'state': + return ( + 'to' in data && + 'holds' in data && + typeof data.to === 'string' && + isWireHoldArray(data.holds) + ); + case 'hold': + return ( + 'seatIds' in data && + 'expiresAt' in data && + isStringArray(data.seatIds) && + typeof data.expiresAt === 'number' + ); + case 'sold': + return 'seatIds' in data && isStringArray(data.seatIds); + case 'goodbye': + return true; + default: + return false; + } +} + +export function translateExpiry(expiresAt: number, sentAt: number, receivedAt: number): number { + const skew = receivedAt - sentAt; + if (Math.abs(skew) <= CLOCK_SKEW_TOLERANCE_MS) return expiresAt; + return expiresAt + skew; +} + +export function toWireHolds(holds: HoldMap): WireHold[] { + return Array.from(holds.values(), (hold) => ({ + seatId: hold.seatId, + owner: hold.owner, + expiresAt: hold.expiresAt, + })); +} + +// Returns the SAME map reference when nothing lapsed - callers rely on this to skip a re-render. +export function pruneHolds(holds: HoldMap, now: number): HoldMap { + let changed = false; + const next = new Map(); + for (const [seatId, hold] of holds) { + if (hold.expiresAt > now) { + next.set(seatId, hold); + } else { + changed = true; + } + } + return changed ? next : holds; +} + +// null means nothing to schedule. +export function nextDeadline(holds: HoldMap, selectionExpiresAt: number | null): number | null { + let min = selectionExpiresAt; + for (const hold of holds.values()) { + if (min === null || hold.expiresAt < min) min = hold.expiresAt; + } + return min; +} + +export interface PeerHoldResult { + holds: HoldMap; + lostSeatIds: string[]; + mustRepost: boolean; +} + +// `message.seatIds` is the sender's entire current claim, so this fully +// replaces (not merges) whatever was previously attributed to that sender. +// A seat both sides claim is resolved by lower-session-id-wins. +export function ingestPeerHold( + holds: HoldMap, + selectedSeats: ReadonlySet, + mySessionId: string, + message: { from: string; seatIds: readonly string[]; expiresAt: number }, +): PeerHoldResult { + if (message.from === mySessionId) { + return { holds, lostSeatIds: [], mustRepost: false }; + } + + const next = new Map(holds); + for (const [seatId, hold] of next) { + if (hold.owner === message.from) next.delete(seatId); + } + + const lostSeatIds: string[] = []; + let mustRepost = false; + + for (const seatId of message.seatIds) { + if (selectedSeats.has(seatId)) { + if (message.from < mySessionId) { + next.set(seatId, { seatId, owner: message.from, expiresAt: message.expiresAt }); + lostSeatIds.push(seatId); + } else { + mustRepost = true; + } + } else { + next.set(seatId, { seatId, owner: message.from, expiresAt: message.expiresAt }); + } + } + + return { holds: next, lostSeatIds, mustRepost }; +} + +// A join-time `state` reply is authoritative (no tie-break, unlike `hold`). +export function adoptRemoteState( + selectedSeats: ReadonlySet, + mySessionId: string, + snapshot: readonly WireHold[], +): { holds: HoldMap; lostSeatIds: string[] } { + const next = new Map(); + const lostSeatIds: string[] = []; + + for (const wire of snapshot) { + if (wire.owner === mySessionId) continue; + next.set(wire.seatId, { seatId: wire.seatId, owner: wire.owner, expiresAt: wire.expiresAt }); + if (selectedSeats.has(wire.seatId)) lostSeatIds.push(wire.seatId); + } + + return { holds: next, lostSeatIds }; +} diff --git a/src/utils/holdSimulation.test.ts b/src/utils/holdSimulation.test.ts new file mode 100644 index 0000000..173a371 --- /dev/null +++ b/src/utils/holdSimulation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { ISeat, Row, Section, Venue } from '../interfaces/venue.interfaces'; +import { SIMULATED_OWNER } from '../interfaces/venue.interfaces'; +import { RELEASE_TICK_MS, SIM_WINDOW_MS } from './holdProtocol'; +import { hash32, seedSimulatedHolds, simulatedExpiry } from './holdSimulation'; + +function makeSeat(id: string, status: ISeat['status'] = 'available'): ISeat { + return { id, col: 1, x: 0, y: 0, priceTier: 1, status }; +} + +function makeVenue(seats: ISeat[]): Venue { + const row: Row = { index: 1, seats }; + const section: Section = { + id: 'S-1', + label: 'Section 1', + transform: { x: 0, y: 0, scale: 1 }, + rows: [row], + }; + return { venueId: 'v', name: 'Venue', map: { width: 100, height: 100 }, sections: [section] }; +} + +const ANCHOR = 1_000_000; + +describe('hash32', () => { + it('is deterministic for the same input', () => { + expect(hash32('SEAT-42')).toBe(hash32('SEAT-42')); + }); + + it('produces different values for different inputs (smoke test)', () => { + const values = new Set(Array.from({ length: 50 }, (_, i) => hash32(`SEAT-${i}`))); + expect(values.size).toBeGreaterThan(40); + }); +}); + +describe('simulatedExpiry', () => { + it('is always within (anchor, anchor + SIM_WINDOW_MS]', () => { + for (let i = 0; i < 50; i++) { + const expiry = simulatedExpiry(`SEAT-${i}`, ANCHOR); + expect(expiry).toBeGreaterThan(ANCHOR); + expect(expiry).toBeLessThanOrEqual(ANCHOR + SIM_WINDOW_MS); + } + }); + + it('is quantized to the release tick grid', () => { + for (let i = 0; i < 50; i++) { + const expiry = simulatedExpiry(`SEAT-${i}`, ANCHOR); + expect((expiry - ANCHOR) % RELEASE_TICK_MS).toBe(0); + } + }); +}); + +describe('seedSimulatedHolds', () => { + it('seeds only seats whose status is held, leaving other statuses untouched', () => { + const seats = [ + makeSeat('A', 'available'), + makeSeat('B', 'held'), + makeSeat('C', 'sold'), + makeSeat('D', 'reserved'), + makeSeat('E', 'held'), + ]; + const holds = seedSimulatedHolds(makeVenue(seats), ANCHOR, new Set()); + expect(Array.from(holds.keys()).sort()).toEqual(['B', 'E']); + }); + + it('skips a held seat that is already in the selection', () => { + const seats = [makeSeat('A', 'held'), makeSeat('B', 'held')]; + const holds = seedSimulatedHolds(makeVenue(seats), ANCHOR, new Set(['A'])); + expect(Array.from(holds.keys())).toEqual(['B']); + }); + + it('owner is the simulated sentinel, never a real session id', () => { + const holds = seedSimulatedHolds(makeVenue([makeSeat('A', 'held')]), ANCHOR, new Set()); + expect(holds.get('A')?.owner).toBe(SIMULATED_OWNER); + }); + + it('is deterministic and order-independent: a shuffled fixture yields the identical map', () => { + const seats = Array.from({ length: 20 }, (_, i) => makeSeat(`H${i}`, 'held')); + const shuffled = [...seats].reverse(); + + const a = seedSimulatedHolds(makeVenue(seats), ANCHOR, new Set()); + const b = seedSimulatedHolds(makeVenue(shuffled), ANCHOR, new Set()); + + expect(Object.fromEntries(a)).toEqual(Object.fromEntries(b)); + }); + + it('staggers releases across multiple buckets rather than firing all at once', () => { + const seats = Array.from({ length: 100 }, (_, i) => makeSeat(`H${i}`, 'held')); + const holds = seedSimulatedHolds(makeVenue(seats), ANCHOR, new Set()); + const buckets = new Set(Array.from(holds.values(), (h) => h.expiresAt)); + expect(buckets.size).toBeGreaterThan(1); + }); +}); diff --git a/src/utils/holdSimulation.ts b/src/utils/holdSimulation.ts new file mode 100644 index 0000000..85f5e03 --- /dev/null +++ b/src/utils/holdSimulation.ts @@ -0,0 +1,43 @@ +import type { HoldMap, SeatHold, Venue } from '../interfaces/venue.interfaces'; +import { SIMULATED_OWNER } from '../interfaces/venue.interfaces'; +import { RELEASE_TICK_MS, SIM_WINDOW_MS } from './holdProtocol'; + +export function hash32(input: string): number { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = (hash * 33) ^ input.charCodeAt(i); + } + return hash >>> 0; +} + +export function simulatedExpiry(seatId: string, anchor: number): number { + const offset = hash32(seatId) % SIM_WINDOW_MS; + const quantized = Math.max( + RELEASE_TICK_MS, + Math.ceil(offset / RELEASE_TICK_MS) * RELEASE_TICK_MS, + ); + return anchor + quantized; +} + +// After this runs, seatStatus.ts stops trusting seat.status === 'held' directly. +export function seedSimulatedHolds( + venue: Venue, + anchor: number, + selectedSeats: ReadonlySet, +): HoldMap { + const holds = new Map(); + for (const section of venue.sections) { + for (const row of section.rows) { + for (const seat of row.seats) { + if (seat.status !== 'held') continue; + if (selectedSeats.has(seat.id)) continue; + holds.set(seat.id, { + seatId: seat.id, + owner: SIMULATED_OWNER, + expiresAt: simulatedExpiry(seat.id, anchor), + }); + } + } + } + return holds; +} diff --git a/src/utils/rowLetter.ts b/src/utils/rowLetter.ts new file mode 100644 index 0000000..cb1c7be --- /dev/null +++ b/src/utils/rowLetter.ts @@ -0,0 +1,14 @@ +// Spreadsheet-style row lettering (A, B, ... Z, AA, AB, ...) so it never runs +// out for a section with more than 26 rows. Shared by the map (Seat.tsx) and +// the booking sidebar (BookingSummary.tsx) so a seat's row reads identically +// in both places instead of "Row 15" on one and "Row O" on the other. +export function rowLetter(rowIndex: number): string { + let n = rowIndex + 1; + let out = ''; + while (n > 0) { + n -= 1; + out = String.fromCharCode(65 + (n % 26)) + out; + n = Math.floor(n / 26); + } + return out; +} diff --git a/src/utils/seatIndex.ts b/src/utils/seatIndex.ts new file mode 100644 index 0000000..a414289 --- /dev/null +++ b/src/utils/seatIndex.ts @@ -0,0 +1,42 @@ +import type { Venue } from '../interfaces/venue.interfaces'; +import { rowLetter } from './rowLetter'; + +export const TIERS: Record = { + 1: { name: 'Premium Stand', price: 7000 }, + 2: { name: 'Standard Stand', price: 3000 }, + 3: { name: 'Gallery Stand', price: 1500 }, +}; +const DEFAULT_TIER = { name: 'Standard Ticket', price: 50 }; +export const getTier = (tier: number) => TIERS[tier] ?? DEFAULT_TIER; + +export interface SeatIndexEntry { + sectionId: string; + sectionLabel: string; + rowLabel: string; + seatCol: number; + tierName: string; + price: number; +} + +// One shared seatId -> details lookup, built once per venue, so the +// selection panel and booking history never independently re-derive (and +// risk drifting on) the same row/tier/price data. +export function buildSeatIndex(venue: Venue): Map { + const index = new Map(); + venue.sections.forEach((section) => { + section.rows.forEach((row) => { + row.seats.forEach((seat) => { + const tier = getTier(seat.priceTier); + index.set(seat.id, { + sectionId: section.id, + sectionLabel: section.label, + rowLabel: rowLetter(row.index - 1), + seatCol: seat.col, + tierName: tier.name, + price: tier.price, + }); + }); + }); + }); + return index; +} diff --git a/src/utils/seatStatus.test.ts b/src/utils/seatStatus.test.ts new file mode 100644 index 0000000..8c208dd --- /dev/null +++ b/src/utils/seatStatus.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import type { HoldMap, SeatHold } from '../interfaces/venue.interfaces'; +import { + SEAT_STATUS_STYLE, + describeSeatStatus, + isSeatSelectable, + resolveSeatStatus, +} from './seatStatus'; + +function holdMap(...holds: SeatHold[]): HoldMap { + return new Map(holds.map((h) => [h.seatId, h])); +} + +const base = { + soldSeats: new Set(), + holds: holdMap(), + selectedSeats: new Set(), + now: 1_000, + simulationSeeded: false, +}; + +describe('resolveSeatStatus', () => { + it('a seat marked sold in venue.json resolves to sold', () => { + expect(resolveSeatStatus({ id: 'S1', status: 'sold' }, base)).toBe('sold'); + }); + + it('a seat bought locally (soldSeats) resolves to sold even if data says available', () => { + const overlays = { ...base, soldSeats: new Set(['S1']) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('sold'); + }); + + it('sold beats an active foreign hold', () => { + const overlays = { + ...base, + soldSeats: new Set(['S1']), + holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 5_000 }), + }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('sold'); + }); + + it('sold beats being in this tab’s own selection', () => { + const overlays = { ...base, soldSeats: new Set(['S1']), selectedSeats: new Set(['S1']) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('sold'); + }); + + it('an active hold (expiresAt in the future) resolves to held', () => { + const overlays = { ...base, holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 5_000 }) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('held'); + }); + + it('expiresAt === now is treated as lapsed, not held', () => { + const overlays = { ...base, holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 1_000 }) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('available'); + }); + + it('expiresAt < now is treated as lapsed', () => { + const overlays = { ...base, holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 500 }) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('available'); + }); + + it('a lapsed hold record falls through to selected when the seat is also mine', () => { + const overlays = { + ...base, + holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 500 }), + selectedSeats: new Set(['S1']), + }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('selected'); + }); + + it('a foreign hold outranks this tab’s own selection (fail-safe ordering)', () => { + const overlays = { + ...base, + holds: holdMap({ seatId: 'S1', owner: 'peer', expiresAt: 5_000 }), + selectedSeats: new Set(['S1']), + }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('held'); + }); + + it('a seat in selectedSeats resolves to selected', () => { + const overlays = { ...base, selectedSeats: new Set(['S1']) }; + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, overlays)).toBe('selected'); + }); + + it('reserved survives a stale/absent hold record', () => { + expect(resolveSeatStatus({ id: 'S1', status: 'reserved' }, base)).toBe('reserved'); + }); + + it('a JSON-held seat resolves to held before the simulation has seeded', () => { + expect( + resolveSeatStatus({ id: 'S1', status: 'held' }, { ...base, simulationSeeded: false }), + ).toBe('held'); + }); + + it('the same JSON-held seat resolves to available once seeded, with no live hold record', () => { + expect( + resolveSeatStatus({ id: 'S1', status: 'held' }, { ...base, simulationSeeded: true }), + ).toBe('available'); + }); + + it('a plain available seat with no overlays resolves to available', () => { + expect(resolveSeatStatus({ id: 'S1', status: 'available' }, base)).toBe('available'); + }); +}); + +describe('isSeatSelectable', () => { + it.each([ + ['available', true], + ['selected', true], + ['sold', false], + ['reserved', false], + ['held', false], + ] as const)('%s -> %s', (status, expected) => { + expect(isSeatSelectable(status)).toBe(expected); + }); +}); + +describe('describeSeatStatus', () => { + it.each([ + ['available', 'available'], + ['selected', 'selected'], + ['sold', 'sold'], + ['reserved', 'reserved'], + ['held', 'temporarily held by another customer'], + ] as const)('%s -> %s', (status, expected) => { + expect(describeSeatStatus(status)).toBe(expected); + }); +}); + +describe('SEAT_STATUS_STYLE', () => { + it('has an entry for every effective status', () => { + (['available', 'selected', 'sold', 'reserved', 'held'] as const).forEach((status) => { + expect(SEAT_STATUS_STYLE[status]).toBeDefined(); + }); + }); +}); diff --git a/src/utils/seatStatus.ts b/src/utils/seatStatus.ts new file mode 100644 index 0000000..d18192b --- /dev/null +++ b/src/utils/seatStatus.ts @@ -0,0 +1,93 @@ +import type { EffectiveSeatStatus, HoldMap, ISeat } from '../interfaces/venue.interfaces'; + +export interface SeatStatusOverlays { + soldSeats: ReadonlySet; + holds: HoldMap; + selectedSeats: ReadonlySet; + now: number; + simulationSeeded: boolean; +} + +export function resolveSeatStatus( + seat: Pick, + overlays: SeatStatusOverlays, +): EffectiveSeatStatus { + const { soldSeats, holds, selectedSeats, now, simulationSeeded } = overlays; + + if (seat.status === 'sold' || soldSeats.has(seat.id)) return 'sold'; + + // Checked before `selected`: on reload, a persisted selection another tab + // already claimed must not render as falsely "mine" before reconciliation runs. + const hold = holds.get(seat.id); + if (hold && hold.expiresAt > now) return 'held'; + + if (selectedSeats.has(seat.id)) return 'selected'; + + if (seat.status === 'reserved') return 'reserved'; + + // venue.json's `held` is a seeding instruction, not a live fact - only + // authoritative before seeding runs, or a lapsed hold would revert to held forever. + if (seat.status === 'held' && !simulationSeeded) return 'held'; + + return 'available'; +} + +export function isSeatSelectable(status: EffectiveSeatStatus): boolean { + return status === 'available' || status === 'selected'; +} + +export function describeSeatStatus(status: EffectiveSeatStatus): string { + switch (status) { + case 'sold': + return 'sold'; + case 'reserved': + return 'reserved'; + case 'held': + return 'temporarily held by another customer'; + case 'selected': + return 'selected'; + case 'available': + return 'available'; + } +} + +interface SeatStyle { + fill: string; + stroke: string; + strokeWidth: number; + textColor: string; +} + +// `available`'s stroke is a fallback; Seat.tsx overrides it per price tier. +export const SEAT_STATUS_STYLE: Record = { + available: { + fill: 'var(--color-seat-available)', + stroke: 'var(--color-seat-available-stroke)', + strokeWidth: 0.35, + textColor: '#334155', + }, + selected: { + fill: 'var(--color-seat-selected)', + stroke: 'white', + strokeWidth: 0.4, + textColor: 'white', + }, + sold: { + fill: 'var(--color-seat-sold)', + stroke: 'none', + strokeWidth: 0, + textColor: '#334155', + }, + reserved: { + fill: 'var(--color-seat-reserved)', + stroke: 'none', + strokeWidth: 0, + textColor: '#334155', + }, + held: { + fill: 'var(--color-seat-held)', + stroke: 'none', + strokeWidth: 0, + textColor: 'white', + }, +}; diff --git a/src/utils/sessionId.test.ts b/src/utils/sessionId.test.ts new file mode 100644 index 0000000..0f319c0 --- /dev/null +++ b/src/utils/sessionId.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const STORAGE_KEY = 'venue-session-id'; + +describe('getSessionId', () => { + beforeEach(() => { + sessionStorage.clear(); + vi.resetModules(); + }); + + it('returns the same id on a second call in the same session', async () => { + const { getSessionId } = await import('./sessionId'); + expect(getSessionId()).toBe(getSessionId()); + }); + + it('reuses an id already present in sessionStorage', async () => { + sessionStorage.setItem(STORAGE_KEY, 'existing-id'); + const { getSessionId } = await import('./sessionId'); + expect(getSessionId()).toBe('existing-id'); + }); + + it('persists a freshly generated id to sessionStorage', async () => { + const { getSessionId } = await import('./sessionId'); + const id = getSessionId(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBe(id); + }); + + it('falls back to a manually generated id when crypto.randomUUID is unavailable', async () => { + const originalRandomUUID = crypto.randomUUID.bind(crypto); + // @ts-expect-error deliberately simulating an older browser without crypto.randomUUID + delete crypto.randomUUID; + try { + const { getSessionId } = await import('./sessionId'); + const id = getSessionId(); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + } finally { + crypto.randomUUID = originalRandomUUID; + } + }); + + it('falls back to an in-memory id when sessionStorage throws', async () => { + const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage'); + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + value: { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }, + }); + try { + const { getSessionId } = await import('./sessionId'); + const id = getSessionId(); + expect(typeof id).toBe('string'); + expect(getSessionId()).toBe(id); + } finally { + if (original) Object.defineProperty(window, 'sessionStorage', original); + } + }); +}); diff --git a/src/utils/sessionId.ts b/src/utils/sessionId.ts new file mode 100644 index 0000000..31a2e69 --- /dev/null +++ b/src/utils/sessionId.ts @@ -0,0 +1,30 @@ +const STORAGE_KEY = 'venue-session-id'; + +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +let cached: string | null = null; + +export function getSessionId(): string { + if (cached) return cached; + + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + cached = stored; + return cached; + } + const fresh = generateId(); + sessionStorage.setItem(STORAGE_KEY, fresh); + cached = fresh; + return fresh; + } catch { + // sessionStorage can throw (e.g. Safari private mode); fall back in-memory. + cached ??= generateId(); + return cached; + } +} diff --git a/src/utils/venueBounds.ts b/src/utils/venueBounds.ts new file mode 100644 index 0000000..1fb1755 --- /dev/null +++ b/src/utils/venueBounds.ts @@ -0,0 +1,42 @@ +import type { Venue } from '../interfaces/venue.interfaces'; + +// Room for the section explode offset, strokes, and labels beyond the +// seats' own bounding box. +export const VENUE_CONTENT_PADDING = 120; + +export interface VenueContentBounds { + minX: number; + minY: number; + width: number; + height: number; +} + +// The venue's actual rendered content extent (the seats' real bounding box, +// plus padding) - not the full map canvas (venue.map.width/height), which is +// deliberately padded with generous built-in margin for panning room and the +// section "explode" offset. Shared by VenueMap.tsx (to size/crop the SVG to +// what's actually drawn, instead of the whole oversized canvas) and App.tsx +// (to compute a zoom that fills the viewport with that content) so the two +// can't drift out of sync with each other. +export function getVenueContentBounds(venue: Venue): VenueContentBounds { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + venue.sections.forEach((section) => { + section.rows.forEach((row) => { + row.seats.forEach((seat) => { + minX = Math.min(minX, seat.x); + maxX = Math.max(maxX, seat.x); + minY = Math.min(minY, seat.y); + maxY = Math.max(maxY, seat.y); + }); + }); + }); + return { + minX: minX - VENUE_CONTENT_PADDING, + minY: minY - VENUE_CONTENT_PADDING, + width: maxX - minX + VENUE_CONTENT_PADDING * 2, + height: maxY - minY + VENUE_CONTENT_PADDING * 2, + }; +} diff --git a/src/utils/viewTransform.test.ts b/src/utils/viewTransform.test.ts new file mode 100644 index 0000000..c0ff33e --- /dev/null +++ b/src/utils/viewTransform.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import type { VenueContentBounds } from './venueBounds'; +import { + MAX_ZOOM, + MIN_ZOOM, + clampPan, + computeFitView, + computeFitZoom, + computeZoomAtPoint, + normalizeWheelDelta, +} from './viewTransform'; + +const bounds: VenueContentBounds = { minX: 0, minY: 0, width: 1000, height: 800 }; + +describe('computeFitZoom', () => { + it('picks the smaller of the width/height ratios so content never overflows either axis', () => { + const zoom = computeFitZoom(bounds, 500, 4000); + expect(zoom).toBeLessThanOrEqual(0.5); + expect(zoom).toBeGreaterThan(0.4); + }); + + it('never returns below MIN_ZOOM even for a tiny viewport', () => { + expect(computeFitZoom(bounds, 1, 1)).toBe(MIN_ZOOM); + }); + + it('caps at 4x even when the viewport dwarfs the content', () => { + expect(computeFitZoom(bounds, 100_000, 100_000)).toBeLessThanOrEqual(4); + }); +}); + +describe('computeFitView', () => { + it('centers the fitted content in the viewport', () => { + const { zoom, pan } = computeFitView(bounds, 2000, 2000); + const contentW = bounds.width * zoom; + const contentH = bounds.height * zoom; + expect(pan.x).toBeCloseTo((2000 - contentW) / 2); + expect(pan.y).toBeCloseTo((2000 - contentH) / 2); + }); +}); + +describe('clampPan', () => { + it('leaves an in-range pan untouched', () => { + const pan = { x: 50, y: 50 }; + expect(clampPan(pan, 1, bounds, 1200, 1000)).toEqual(pan); + }); + + it('stops content from being dragged entirely off the right/bottom edge', () => { + const pan = clampPan({ x: -100_000, y: -100_000 }, 1, bounds, 1200, 1000); + expect(pan.x).toBeGreaterThan(-bounds.width); + expect(pan.y).toBeGreaterThan(-bounds.height); + }); + + it('stops content from being dragged entirely off the left/top edge', () => { + const pan = clampPan({ x: 100_000, y: 100_000 }, 1, bounds, 1200, 1000); + expect(pan.x).toBeLessThan(1200); + expect(pan.y).toBeLessThan(1000); + }); + + it('falls back to centering when content is too small for the margin on both sides', () => { + const tinyBounds: VenueContentBounds = { minX: 0, minY: 0, width: 20, height: 20 }; + const pan = clampPan({ x: 999, y: 999 }, 1, tinyBounds, 100, 100); + expect(pan.x).toBeCloseTo((100 - 20) / 2); + expect(pan.y).toBeCloseTo((100 - 20) / 2); + }); +}); + +describe('computeZoomAtPoint', () => { + it('keeps the content point under the pointer stationary after zooming in', () => { + const current = { zoom: 1, pan: { x: 0, y: 0 } }; + const pointerX = 300; + const pointerY = 200; + const next = computeZoomAtPoint(current, 2, pointerX, pointerY, bounds, 1200, 1000); + + expect(next.zoom).toBe(2); + const screenX = next.pan.x + 300 * next.zoom; + const screenY = next.pan.y + 200 * next.zoom; + expect(screenX).toBeCloseTo(pointerX); + expect(screenY).toBeCloseTo(pointerY); + }); + + it('keeps the anchor stationary when zooming out too', () => { + const current = { zoom: 2, pan: { x: -100, y: -50 } }; + const pointerX = 400; + const pointerY = 300; + const worldXBefore = (pointerX - current.pan.x) / current.zoom; + const worldYBefore = (pointerY - current.pan.y) / current.zoom; + + const next = computeZoomAtPoint(current, 1, pointerX, pointerY, bounds, 1200, 1000); + + const screenX = next.pan.x + worldXBefore * next.zoom; + const screenY = next.pan.y + worldYBefore * next.zoom; + expect(screenX).toBeCloseTo(pointerX); + expect(screenY).toBeCloseTo(pointerY); + }); + + it('clamps the requested zoom to [MIN_ZOOM, MAX_ZOOM]', () => { + const current = { zoom: 1, pan: { x: 0, y: 0 } }; + expect(computeZoomAtPoint(current, 999, 0, 0, bounds, 1200, 1000).zoom).toBe(MAX_ZOOM); + expect(computeZoomAtPoint(current, 0.0001, 0, 0, bounds, 1200, 1000).zoom).toBe(MIN_ZOOM); + }); + + it('is a no-op when the clamped zoom equals the current zoom', () => { + const current = { zoom: MAX_ZOOM, pan: { x: 5, y: 5 } }; + expect(computeZoomAtPoint(current, 999, 123, 456, bounds, 1200, 1000)).toBe(current); + }); +}); + +describe('normalizeWheelDelta', () => { + it('passes pixel-mode deltas through unchanged', () => { + expect(normalizeWheelDelta(10, -20, 0)).toEqual({ x: 10, y: -20 }); + }); + + it('scales line-mode deltas up to pixel-ish units', () => { + const { x, y } = normalizeWheelDelta(1, -2, 1); + expect(x).toBeGreaterThan(1); + expect(y).toBeLessThan(-2); + }); +}); diff --git a/src/utils/viewTransform.ts b/src/utils/viewTransform.ts new file mode 100644 index 0000000..71af92c --- /dev/null +++ b/src/utils/viewTransform.ts @@ -0,0 +1,101 @@ +import type { VenueContentBounds } from './venueBounds'; + +export interface Pan { + x: number; + y: number; +} + +export interface ViewState { + zoom: number; + pan: Pan; +} + +export const MIN_ZOOM = 0.2; +export const MAX_ZOOM = 8; +export const ZOOM_STEP = 0.2; + +export const DEFAULT_TRANSITION_MS = 200; +export const SECTION_FOCUS_TRANSITION_MS = 650; + +const FIT_SAFETY_MARGIN = 0.96; +const PAN_VISIBLE_MARGIN = 80; + +export function computeFitZoom( + bounds: VenueContentBounds, + availableWidth: number, + availableHeight: number, +): number { + const fitScale = Math.min(availableWidth / bounds.width, availableHeight / bounds.height, 4); + return Math.max(fitScale * FIT_SAFETY_MARGIN, MIN_ZOOM); +} + +export function computeFitView( + bounds: VenueContentBounds, + containerWidth: number, + containerHeight: number, +): ViewState { + const zoom = computeFitZoom(bounds, containerWidth - 64, containerHeight - 64); + return { + zoom, + pan: { + x: (containerWidth - bounds.width * zoom) / 2, + y: (containerHeight - bounds.height * zoom) / 2, + }, + }; +} + +function clampPanAxis(value: number, containerSize: number, contentSize: number): number { + const min = PAN_VISIBLE_MARGIN - contentSize; + const max = containerSize - PAN_VISIBLE_MARGIN; + if (min <= max) return Math.min(Math.max(value, min), max); + return (containerSize - contentSize) / 2; +} + +export function clampPan( + pan: Pan, + zoom: number, + bounds: VenueContentBounds, + containerWidth: number, + containerHeight: number, +): Pan { + return { + x: clampPanAxis(pan.x, containerWidth, bounds.width * zoom), + y: clampPanAxis(pan.y, containerHeight, bounds.height * zoom), + }; +} + +export function computeZoomAtPoint( + current: ViewState, + newZoomRaw: number, + pointerX: number, + pointerY: number, + bounds: VenueContentBounds, + containerWidth: number, + containerHeight: number, +): ViewState { + const newZoom = Math.min(Math.max(newZoomRaw, MIN_ZOOM), MAX_ZOOM); + if (newZoom === current.zoom) return current; + + const worldX = (pointerX - current.pan.x) / current.zoom; + const worldY = (pointerY - current.pan.y) / current.zoom; + + const pan = clampPan( + { x: pointerX - worldX * newZoom, y: pointerY - worldY * newZoom }, + newZoom, + bounds, + containerWidth, + containerHeight, + ); + return { zoom: newZoom, pan }; +} + +export function normalizeWheelDelta(deltaX: number, deltaY: number, deltaMode: number): Pan { + const LINE_HEIGHT_PX = 16; + if (deltaMode === 1) { + return { x: deltaX * LINE_HEIGHT_PX, y: deltaY * LINE_HEIGHT_PX }; + } + if (deltaMode === 2) { + return { x: deltaX * window.innerWidth, y: deltaY * window.innerHeight }; + } + return { x: deltaX, y: deltaY }; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..89c3f51 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,6 +6,11 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "types": ["vite/client"], + // The org standard wants this false, but flipping it here surfaces + // internal type-declaration conflicts inside vitest/vite/testing-library + // themselves (e.g. duplicate `Assertion` type param declarations, + // `@vitest/expect` module resolution) - not our code, and not fixable + // from this project. Documented exception rather than silently ignored. "skipLibCheck": true, /* Bundler mode */ @@ -22,7 +27,15 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true }, "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index 897259d..9905aae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,4 +12,4 @@ "strict": true, "noImplicitAny": true } -} \ No newline at end of file +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..e6d5bf5 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,6 +5,7 @@ "lib": ["ES2023"], "module": "ESNext", "types": ["node"], + // See tsconfig.app.json for why this stays true despite the standard. "skipLibCheck": true, /* Bundler mode */ @@ -20,7 +21,15 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "scripts"] } diff --git a/vite.config.ts b/vite.config.ts index a10811b..c59615a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,26 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss(),], - -}) + plugins: [react(), tailwindcss()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + reportsDirectory: 'coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/main.tsx', 'src/interfaces/**', 'src/test/**', '**/*.test.*'], + // Regression floor, ratcheted up (never down) as coverage improves - see engineering-notes.md. + thresholds: { + branches: 55, + functions: 51, + lines: 55, + statements: 54, + }, + }, + }, +});