@@ -22,7 +25,7 @@ export function VenueCard({ venue, beerNameBySlug }: VenueCardProps) {
{venue.name}
- {venue.locationName}
+ {venueCardLocation(venue)}
{typeLabels[venue.type]}
diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index a78ccd4..133bafa 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -126,7 +126,7 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
| `lib/` | Shared logic. Client-safe: `firebase.ts`, `beers.ts`, `venues.ts`, `analytics.ts`, `types.ts`, `utils.ts`, `email.ts`, `trade-leads-common.ts`, admin `*-common`/`admin-format.ts` helpers. Server-only (`import "server-only"`): `firebase-admin.ts`, `admin-auth.ts`, `admin-users.ts`, `admin-invitations.ts`, `admin-invitation-email.ts`, `admin-invitation-resend-core.ts`, `admin-audit.ts`, `trade-leads.ts`. Policy/serialization helpers shared by both: `admin-policy.ts`, `admin-serializers.ts`, `admin-invitation-policy.ts`, `admin-invitation-resend-policy.ts`, `admin-types.ts`. |
| `tests/` | Node `node:test` unit tests (`tsx` loader) for admin/auth/invitation/audit helpers and for the *contents* of `firestore.rules` and `storage.rules`. |
| `rules-tests/` | Emulator-backed security-rules tests (`@firebase/rules-unit-testing` against the Firestore/Storage emulators). Run via `npm run test:rules`, which wraps `firebase emulators:exec`; each file uses its own `demo-*` project so parallel `node:test` files stay isolated. |
-| `scripts/` | Local/manual tooling: Playwright diagnostics (`*-check.mjs`, `hero-video-network.mjs`), `check-md-links.mjs`, `check-react-versions.mjs`, `optimize-assets.mjs`, `bootstrap-superadmin.ts`, `prune-trade-leads.ts`, `seed-beers.ts`, `seed-venues.ts`. `check-md-links.mjs` and `check-react-versions.mjs` run in CI; the Playwright diagnostics and prune script do not (CI browser coverage lives in `smoke-tests/`). |
+| `scripts/` | Local/manual tooling: Playwright diagnostics (`*-check.mjs`, `hero-video-network.mjs`), `check-md-links.mjs`, `check-react-versions.mjs`, `optimize-assets.mjs`, `bootstrap-superadmin.ts`, `prune-trade-leads.ts`, `migrate-venue-islands.ts`, `seed-beers.ts`, `seed-venues.ts`. `check-md-links.mjs` and `check-react-versions.mjs` run in CI; the Playwright diagnostics and data scripts do not (CI browser coverage lives in `smoke-tests/`). |
| `docs/` | Admin handbook (`docs/admin/`), operations guides (`docs/operations/`: deployment, troubleshooting, post-deploy checklist), and this file. |
| `content/` | Legacy placeholder (`.gitkeep` only). MDX content is co-located under `app/(pages)/`; do not add files here expecting them to render. |
| `firestore.rules`, `storage.rules` | Firebase security rules — see §6/§15. |
@@ -148,7 +148,7 @@ moving it into `lib/` or API routes without an explicit issue.
| `/` | `app/page.tsx` | Server (static) | `beers` collection via `getBeers()` | Home page; hero, featured beer/carousel, intro, brewery/CTA sections. |
| `/beers` | `app/(pages)/beers/page.tsx` | Server (static) | `beers` via `getBeers()` | Catalog grid; `BeersFilterGrid` (client) provides filtering. |
| `/beers/[slug]` | `app/(pages)/beers/[slug]/page.tsx` | Server (**static**, `generateStaticParams` + `dynamicParams = false`) | `beers` via `getBeerBySlug(slug)` at build time | Beer detail; `generateMetadata` per slug, JSON-LD, `BeerViewTracker` (client) emits `beer_detail_view`; unknown slugs 404. |
-| `/where-to-buy` | `app/(pages)/where-to-buy/page.tsx` | Server (static) | `venues` + `beers` | Venue list grouped by island (Saba, SXM, Statia normalization in `lib/venue-filters.ts`), `VenueCard` entries, client-side beer/format/island filtering via `VenueDirectory` (URL-mirrored `?beer=&format=&island=` state). |
+| `/where-to-buy` | `app/(pages)/where-to-buy/page.tsx` | Server (static) | `venues` + `beers` | Venue list grouped by the canonical `island` field (`lib/venue-islands.ts`, legacy `locationName` fallback in `lib/venue-filters.ts`), `VenueCard` entries, client-side beer/format/island filtering via `VenueDirectory` (URL-mirrored `?beer=&format=&island=` state). |
| `/about` | `app/(pages)/about/page.mdx` | Server (static) | none | MDX content styled by `mdx-components.tsx`. |
| `/contact` | `app/(pages)/contact/page.tsx` | Server (static) | none | Contact details; `TrackedAnchor` for click analytics. |
| `/trade` | `app/(pages)/trade/page.tsx` | Server (static) | none | Wholesale/trade page hosting `TradeInquiryForm` (client). See the `/trade` note below. |
@@ -202,10 +202,15 @@ in code are listed.
- **Purpose:** where-to-buy partner listings.
- **Key fields (`Venue` in `lib/types.ts`):** `name`, `slug`, `type`
- (`"bar_restaurant" | "retail"`), `locationName` (island/area string,
- normalized at render time), `carriesBeerSlugs[]`, optional
+ (`"bar_restaurant" | "retail"`), `island` (canonical island key —
+ `"saba" | "sxm" | "statia"`, enforced on writes by `firestore.rules`;
+ see `lib/venue-islands.ts`), `locationName` (free-text locality shown on
+ the venue card — never used for grouping), `carriesBeerSlugs[]`, optional
`tapBeerSlugs[]`/`canBeerSlugs[]`, `isPublic`, `sortOrder`, `links`
(`website`/`maps`/`instagram`/`facebook`/`untappd`), `notesPublic`.
+ Issue #134 split island identity out of `locationName`; reads fall back
+ to parsing `locationName` for legacy records until the
+ `migrate:venue-islands` backfill completes.
- **Reads:** `/where-to-buy` via `getVenues()` (`isPublic` + `sortOrder`);
rules allow public reads only of `isPublic` docs; admin dashboard reads all.
- **Writes:** admin dashboard `setDoc` merge keyed by `slug` (client SDK).
@@ -446,8 +451,9 @@ server-side.
visibility and ordering; `status` (`core`/`seasonal`/`limited`) is display
metadata.
- **Venue data:** `getVenues()` queries `venues` where `isPublic == true`,
- ordered by `sortOrder`. `/where-to-buy` groups by `locationName` with island
- normalization (Saba / SXM / Statia), maps `carriesBeerSlugs`/`tapBeerSlugs`/
+ ordered by `sortOrder`. `/where-to-buy` groups by the canonical `island`
+ field (`saba` / `sxm` / `statia`, with a transitional `locationName`
+ inference fallback for unmigrated records), maps `carriesBeerSlugs`/`tapBeerSlugs`/
`canBeerSlugs` to beer names, and renders `VenueCard` entries with their
`links` and `notesPublic`. `VenueDirectory` (`components/venue-directory.tsx`)
is a client component that filters the server-rendered list — logic lives in
@@ -477,7 +483,7 @@ All of these live in `components/admin-dashboard.tsx` (client) plus
| Operation | Mechanism | Enforcement |
| --- | --- | --- |
| Load beers/venues | Client SDK reads (all docs, `sortOrder` asc — including non-public) | Rules: public sees `isPublic` only; active admin (claim + matching record) reads all |
-| Save beer / venue | Client SDK `setDoc(doc(db, "beers"|"venues", slug), payload, { merge: true })` — doc id is the slug | `hasActiveAdmin` in `firestore.rules` (claim + active matching `adminUsers` record) |
+| Save beer / venue | Client SDK `setDoc(doc(db, "beers"|"venues", slug), payload, { merge: true })` — doc id is the slug | `hasActiveAdmin` in `firestore.rules` (claim + active matching `adminUsers` record); venue writes additionally require a canonical `island` (`saba`/`sxm`/`statia`) |
| Upload images | Client SDK `uploadBytes` to Storage | `hasActiveAdmin` in `storage.rules` (claim + cross-service `firestore.get()` active-record check) |
| Update rebuild metadata | Client SDK `setDoc` merge on `meta/siteRebuild` (`contentUpdatedAt/By`, `lastTriggeredAt/By`, `cooldownUntil`) | rules gate `meta` to active admins |
| Trigger rebuild | `POST /api/admin/rebuild` with Bearer token | Server: `requireAdminActor` (claims + active matching `adminUsers` record) + in-memory cooldown → POST to Vercel deploy hook |
@@ -741,7 +747,8 @@ placeholder values exist anywhere in CI.
- **Local-only scripts (`scripts/`):** Playwright-based manual diagnostics
(`screenshot-check`, `overflow-check`, `hero-video-*`),
`optimize-assets.mjs`, and Admin-SDK utilities (`bootstrap-superadmin.ts`,
- `prune-trade-leads.ts`, `seed-beers.ts`, `seed-venues.ts`). These remain
+ `prune-trade-leads.ts`, `migrate-venue-islands.ts`, `seed-beers.ts`,
+ `seed-venues.ts`). These remain
manual/local; the CI smoke suite lives in `smoke-tests/`.
- **Verification parity:** local pre-PR checks are the same commands CI
runs, per `AGENTS.md`.
diff --git a/docs/admin/managing-locations.md b/docs/admin/managing-locations.md
index 19d31a9..d9b2a25 100644
--- a/docs/admin/managing-locations.md
+++ b/docs/admin/managing-locations.md
@@ -32,7 +32,8 @@ This guide explains how to add, edit, and remove partner venues from the Deep Di
| **Name** | Yes | The venue's display name. |
| **Slug** | Yes | The URL-friendly identifier. Used as the Firestore document ID. Should be lowercase and hyphenated, for example `tropics-cafe`. |
| **Type** | Yes | Choose **Bar / Restaurant** or **Retail**. |
-| **Location Name** | Recommended | The island or region. This controls how venues are grouped on `/where-to-buy`. See the region section below for exact values. |
+| **Island** | Yes | The island the venue is on. A controlled dropdown — pick one of the listed islands. This controls grouping and the Island filter on `/where-to-buy`. |
+| **Location / locality** | Optional | A more specific place on the island, for example `Windwardside` or `Philipsburg`. Shown on the venue card next to the island. Leave blank if the island alone is enough. It never affects grouping. |
| **Sort Order** | Yes | Controls the order venues appear within their group. Lower numbers appear first. |
| **Carries Beers** | Optional | Check every beer the venue currently carries in any format. |
| **On Tap** | Optional | Check beers the venue has on draft tap. |
@@ -44,37 +45,28 @@ This guide explains how to add, edit, and remove partner venues from the Deep Di
| **Public Notes** | Optional | Extra text shown on the public venue card, for example "Ask about the rotating tap." |
| **Public** | Yes | If checked, the venue is visible on `/where-to-buy`. Uncheck to hide it. |
-## Region names and grouping logic
+## Island and locality
-The `/where-to-buy` page groups venues by the **Location Name** field. The public page uses these rules to decide the heading for each group:
+The `/where-to-buy` page groups venues by the **Island** field — a controlled dropdown with exactly these options:
-- If the location name contains `sxm`, `maarten`, or `martin`, the heading displays as **Sint Maarten / Saint Martin**.
-- If the location name contains `statia` or `eustatius`, the heading displays as **Sint Eustatius / Statia**.
-- `Saba` — or any value whose last comma-separated part is `Saba`, such as `Windwardside, Saba` or `Fort Bay, Saba` — groups under **Saba**. The locality prefix still appears on the venue card but does not create its own group.
-- Any other value becomes its own group heading, capitalized word by word.
+- **Saba**
+- **Sint Maarten / Saint Martin**
+- **Sint Eustatius / Statia**
-### Recommended exact values
+Island is required; a venue cannot be saved without one, and free-text island names are impossible by design. (Internally the values are stored as `saba`, `sxm`, and `statia` — the internal codes never appear publicly.)
-Use simple, consistent values so grouping works predictably:
+**Location / locality** is a separate, optional free-text field for a more specific place — `Windwardside`, `The Bottom`, `Fort Bay`, `Philipsburg`. It is shown on the venue card together with the island (for example `Windwardside, Saba` or `Philipsburg, Sint Maarten`) but never affects grouping or the Island filter. Leave it blank when the island alone is enough — the card then shows just the island label.
-- `Saba`
-- `SXM` (or `Sint Maarten`)
-- `Statia` (or `Sint Eustatius`)
-
-Examples:
-
-- A venue on Saba should have **Location Name** set to `Saba`. If the locality matters to visitors, `
, Saba` also works — for example `Windwardside, Saba` still groups under **Saba** while the card shows the locality.
-- A venue on Sint Maarten should use `SXM` or `Sint Maarten`.
-- A venue on Saint Martin (French side) should use `Saint Martin` or `SXM`.
+> **Why two fields?** Locality text used to double as the island, which let values like `Philipsburg` or `Windwardside` accidentally become their own public island group. Older records were migrated so `locationName` holds only the locality; reads still understand the old combined format until every record is migrated, so nothing breaks in between.
## How locations appear on `/where-to-buy`
- Only venues with **Public** checked are shown.
-- Venues are grouped under headings based on **Location Name**.
+- Venues are grouped under headings based on **Island**.
- Inside each group, venues are sorted by **Sort Order**.
-- Each venue card shows the name, type badge, public notes, and the beers listed under **On Tap** and **In Can**.
+- Each venue card shows the name, type badge, location, public notes, and the beers listed under **On Tap** and **In Can**.
- Links for Website, Directions, Instagram, and Facebook appear when those fields are filled.
-- Visitors can filter the list by **Beer**, **Format** (On Tap / In Can), and — when venues span more than one island — **Island**. The beer filter only lists beers that at least one public venue carries; the format and island filters read the same **On Tap**, **In Can**, and **Location Name** fields described above, so keeping them accurate keeps the filters useful. These lists describe what a venue is known to carry — they are not live stock counts.
+- Visitors can filter the list by **Beer**, **Format** (On Tap / In Can), and — when venues span more than one island — **Island**. The beer filter only lists beers that at least one public venue carries; the format and island filters read the same **On Tap**, **In Can**, and **Island** fields described above, so keeping them accurate keeps the filters useful. These lists describe what a venue is known to carry — they are not live stock counts.
## Making Directions links work reliably
@@ -127,7 +119,8 @@ Before saving a new or updated venue, confirm:
- [ ] Name and slug are correct and unique.
- [ ] Slug is lowercase, hyphenated, and URL-safe.
- [ ] Type is correct (Bar / Restaurant or Retail).
-- [ ] Location Name uses one of the recommended region values.
+- [ ] Island is selected from the dropdown (required).
+- [ ] Location / locality is filled in only if it adds useful context.
- [ ] Sort order is set.
- [ ] Website link is a full `https://` URL (if provided).
- [ ] Maps Link is a working URL that opens directions (if provided).
diff --git a/firestore.rules b/firestore.rules
index b62fea2..5966005 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -46,9 +46,18 @@ service cloud.firestore {
allow create, update, delete: if hasActiveAdmin();
}
+ // Issue #134: venue writes must carry a canonical `island` value so a
+ // free-text locality can never become a public island group. The merged
+ // post-write document is checked, so update/merge writes on already-
+ // migrated docs stay valid. Delete is exempt — request.resource is null.
+ function venueHasCanonicalIsland() {
+ return request.resource.data.island in ['saba', 'sxm', 'statia'];
+ }
+
match /venues/{venueId} {
allow read: if isPublicDoc() || hasActiveAdmin();
- allow create, update, delete: if hasActiveAdmin();
+ allow create, update: if hasActiveAdmin() && venueHasCanonicalIsland();
+ allow delete: if hasActiveAdmin();
}
// Trade inquiries are PII and are written exclusively server-side by
diff --git a/lib/types.ts b/lib/types.ts
index 5e90449..1ab3218 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -16,11 +16,19 @@ export interface Beer {
sortOrder: number;
}
+import type { VenueIsland } from "@/lib/venue-islands";
+
export interface Venue {
name: string;
slug: string;
type: "bar_restaurant" | "retail";
+ // Free-text locality (e.g. "Windwardside", "Philipsburg") shown on the
+ // venue card. Never used for island grouping — that is `island`'s job.
locationName: string;
+ // Canonical island key ("saba" | "sxm" | "statia"). Optional only because
+ // legacy documents predate the field; reads fall back to parsing
+ // `locationName` until migration completes (see lib/venue-filters.ts).
+ island?: VenueIsland;
carriesBeerSlugs: string[];
tapBeerSlugs?: string[];
canBeerSlugs?: string[];
diff --git a/lib/venue-filters.ts b/lib/venue-filters.ts
index 7806e9a..c0b6164 100644
--- a/lib/venue-filters.ts
+++ b/lib/venue-filters.ts
@@ -1,4 +1,10 @@
import type { Beer, Venue } from "@/lib/types";
+import {
+ isVenueIsland,
+ VENUE_ISLAND_CARD_LABELS,
+ VENUE_ISLAND_LABELS,
+ type VenueIsland,
+} from "@/lib/venue-islands";
// Pure filtering/grouping logic for the /where-to-buy venue directory.
// Firebase-free: this module is imported by a client component, so it must
@@ -8,7 +14,11 @@ import type { Beer, Venue } from "@/lib/types";
// - `carriesBeerSlugs[]` — every beer the venue carries in any format
// - `tapBeerSlugs[]` — beers currently marked On Tap
// - `canBeerSlugs[]` — beers currently marked In Can
-// - `locationName` — free-text island/area, normalized to a canonical key
+// - `island` — canonical island key ("saba" | "sxm" | "statia"); drives all
+// public grouping/filtering (Issue #134)
+// - `locationName` — free-text locality for card display only; on legacy
+// records it still encodes the island, so reads fall back to parsing it
+// (transitional — see `venueIslandKey`)
// These fields describe a venue listing, not live inventory; nothing here
// should be read as real-time stock or guaranteed availability.
@@ -65,12 +75,16 @@ export function venueOffersFormat(
}
/**
- * Canonical island key for a `locationName`. "sxm"/maarten/martin collapse
- * to "sxm", statia/eustatius to "statia", and "saba" plus Saba localities
- * written ", Saba" ("Fort Bay, Saba", "Windwardside / The
- * Bottom, Saba") collapse to "saba"; anything else lowercases as-is. An
- * empty/missing location follows the same Saba default the public page
- * has always used.
+ * Legacy island inference from a `locationName`. "sxm"/maarten/martin
+ * collapse to "sxm", statia/eustatius to "statia", and "saba" plus Saba
+ * localities written ", Saba" ("Fort Bay, Saba", "Windwardside /
+ * The Bottom, Saba") collapse to "saba"; anything else lowercases as-is. An
+ * empty/missing location follows the same Saba default the public page has
+ * always used.
+ *
+ * Transitional (Issue #134): only used for records that predate the
+ * canonical `island` field — `venueIslandKey` is the entry point for venue
+ * records. Do not remove until every stored venue carries `island`.
*/
export function islandKey(locationName: string | undefined): string {
const normalized = (locationName ?? "").trim().toLowerCase();
@@ -94,30 +108,128 @@ export function islandKey(locationName: string | undefined): string {
return normalized === "" ? "saba" : normalized;
}
-// Intentional public labels for the known canonical islands — never
-// derived from casing rules. A Map (not a Record) so keys colliding with
-// Object.prototype members still take the unknown-island path.
-const ISLAND_DISPLAY_NAMES: ReadonlyMap = new Map([
- ["saba", "Saba"],
- ["sxm", "Sint Maarten / Saint Martin"],
- ["statia", "Sint Eustatius / Statia"],
+/**
+ * Canonical island key for a venue: the stored `island` field when present,
+ * falling back to legacy `locationName` inference for records written before
+ * Issue #134. New writes always set `island`, so grouping no longer depends
+ * on parsing locality text — the fallback exists only until migration
+ * completes.
+ */
+export function venueIslandKey(venue: Venue): string {
+ return isVenueIsland(venue.island)
+ ? venue.island
+ : islandKey(venue.locationName);
+}
+
+/**
+ * Island value for the admin venue editor: the stored canonical `island`,
+ * or the legacy `locationName` inference when it resolves to a known island
+ * so existing records preselect correctly. Returns undefined when the
+ * locality text is not a recognizable island (e.g. a bare "Philipsburg") —
+ * the editor then leaves the required select unset rather than guessing.
+ */
+export function resolveVenueIsland(venue: Venue): VenueIsland | undefined {
+ if (isVenueIsland(venue.island)) return venue.island;
+ const legacy = islandKey(venue.locationName);
+ return isVenueIsland(legacy) ? legacy : undefined;
+}
+
+/**
+ * Known-locality → island map for the venue migration (Issue #134). Only
+ * entries that are unambiguous within the brewery's operating region belong
+ * here — a bare locality string can never reach this map through inference.
+ * "Oranjestad" is deliberately absent: it is also the capital of Aruba, so a
+ * bare "Oranjestad" record stays unresolved and is reported for owner review
+ * rather than guessed.
+ */
+const KNOWN_LOCALITY_ISLANDS: ReadonlyMap = new Map([
+ ["windwardside", "saba"],
+ ["the bottom", "saba"],
+ ["fort bay", "saba"],
+ ["philipsburg", "sxm"],
]);
+export interface VenueGeography {
+ island: VenueIsland;
+ locality: string;
+}
+
+/**
+ * Split a legacy `locationName` into canonical `{ island, locality }` for
+ * the Issue #134 backfill. ", " forms keep their locality
+ * prefix; a bare island name ("Saba", "SXM", "Sint Maarten") yields an empty
+ * locality; a bare locality is mapped only via KNOWN_LOCALITY_ISLANDS.
+ * Returns null when the value cannot be classified confidently — callers
+ * must report it for owner review instead of writing a guess.
+ */
+export function resolveVenueGeography(
+ locationName: string | undefined
+): VenueGeography | null {
+ const parts = (locationName ?? "")
+ .split(",")
+ .map((part) => part.trim())
+ .filter((part) => part !== "");
+
+ // The legacy Saba default for an empty location, preserved verbatim.
+ if (parts.length === 0) return { island: "saba", locality: "" };
+
+ const lastKey = islandKey(parts[parts.length - 1]);
+ if (isVenueIsland(lastKey)) {
+ return { island: lastKey, locality: parts.slice(0, -1).join(", ") };
+ }
+
+ const whole = parts.join(", ");
+ const known = KNOWN_LOCALITY_ISLANDS.get(whole.toLowerCase());
+ if (known) return { island: known, locality: whole };
+
+ return null;
+}
+
/**
* Display heading for a canonical island key. Known islands return their
* canonical label; an unknown future island key title-cases each word
- * rather than only the first letter of the whole string.
+ * rather than only the first letter of the whole string. `isVenueIsland`
+ * (not a Record lookup) keeps keys colliding with Object.prototype members
+ * on the unknown-island path.
*/
export function islandDisplayName(key: string): string {
- return (
- ISLAND_DISPLAY_NAMES.get(key) ??
- key.replace(/\b[a-z]/g, (ch) => ch.toUpperCase())
- );
+ return isVenueIsland(key)
+ ? VENUE_ISLAND_LABELS[key]
+ : key.replace(/\b[a-z]/g, (ch) => ch.toUpperCase());
+}
+
+/**
+ * Location line for a venue card. Migrated venues compose
+ * ", " from the canonical field using the short
+ * jurisdiction label (e.g. "Philipsburg, Sint Maarten" — the
+ * dual-jurisdiction label would be wrong as a locality address); a blank
+ * locality — or one that already equals the island label — shows just the
+ * whole-island label ("Saba", never "Saba, Saba"). Legacy records without
+ * `island` keep showing their raw `locationName` unchanged.
+ */
+export function venueCardLocation(venue: Venue): string {
+ if (!isVenueIsland(venue.island)) return venue.locationName;
+ const locality = venue.locationName.trim();
+ const cardLabel = VENUE_ISLAND_CARD_LABELS[venue.island];
+ if (
+ locality === "" ||
+ locality.toLowerCase() === cardLabel.toLowerCase() ||
+ locality.toLowerCase() === VENUE_ISLAND_LABELS[venue.island].toLowerCase()
+ ) {
+ return VENUE_ISLAND_LABELS[venue.island];
+ }
+ // A locality entered in the old combined format ("Windwardside, Saba")
+ // already ends with the island name — show it as-is instead of producing
+ // "Windwardside, Saba, Saba".
+ if (islandKey(locality.split(",").pop()) === venue.island) {
+ return locality;
+ }
+ return `${locality}, ${cardLabel}`;
}
/** Distinct canonical island keys across venues, ordered by display name. */
export function distinctIslands(venues: Venue[]): string[] {
- const keys = new Set(venues.map((venue) => islandKey(venue.locationName)));
+ const keys = new Set(venues.map(venueIslandKey));
return [...keys].sort((a, b) =>
islandDisplayName(a).localeCompare(islandDisplayName(b))
);
@@ -125,15 +237,17 @@ export function distinctIslands(venues: Venue[]): string[] {
/**
* Group venues by canonical island key, ordered by display name. Grouping by
- * the canonical key (rather than the raw locationName) merges harmless
- * spelling variants like "SXM" and "Sint Maarten" into one section.
+ * the canonical `island` field means a locality like "Philipsburg" or
+ * "Windwardside" can never become its own section (Issue #134); the legacy
+ * `locationName` fallback inside `venueIslandKey` still merges spelling
+ * variants like "SXM" and "Sint Maarten" for unmigrated records.
*/
export function groupVenuesByIsland(
venues: Venue[]
): Array<{ key: string; venues: Venue[] }> {
const byIsland = new Map();
for (const venue of venues) {
- const key = islandKey(venue.locationName);
+ const key = venueIslandKey(venue);
byIsland.set(key, [...(byIsland.get(key) ?? []), venue]);
}
return [...byIsland.entries()]
@@ -154,7 +268,7 @@ export function filterVenues(venues: Venue[], filters: VenueFilters): Venue[] {
return venues.filter((venue) => {
if (
filters.island !== null &&
- islandKey(venue.locationName) !== filters.island
+ venueIslandKey(venue) !== filters.island
) {
return false;
}
diff --git a/lib/venue-islands.ts b/lib/venue-islands.ts
new file mode 100644
index 0000000..93ab3eb
--- /dev/null
+++ b/lib/venue-islands.ts
@@ -0,0 +1,50 @@
+// Canonical island vocabulary for venue records (Issue #134). The `island`
+// field on a Venue is the controlled grouping identity; `locationName` is a
+// free-text locality that must never determine island grouping.
+//
+// Firebase-free: this module is imported by client components, so it must
+// never pull in the Firebase client SDK (see docs/operations/performance.md).
+
+/** Stable machine values stored in the venue `island` field. */
+export const VENUE_ISLAND_KEYS = ["saba", "sxm", "statia"] as const;
+
+export type VenueIsland = (typeof VENUE_ISLAND_KEYS)[number];
+
+const VENUE_ISLAND_SET: ReadonlySet = new Set(VENUE_ISLAND_KEYS);
+
+/** True when `value` is a canonical island key. */
+export function isVenueIsland(value: unknown): value is VenueIsland {
+ return typeof value === "string" && VENUE_ISLAND_SET.has(value);
+}
+
+/**
+ * Whole-island customer-facing labels, used for /where-to-buy group headings
+ * and island filter options. "sxm" is internal-only — the public label is the
+ * full dual-jurisdiction name.
+ */
+export const VENUE_ISLAND_LABELS: Readonly> = {
+ saba: "Saba",
+ sxm: "Sint Maarten / Saint Martin",
+ statia: "Sint Eustatius / Statia",
+};
+
+/**
+ * Short jurisdiction labels for composing ", " card text.
+ * The dual-jurisdiction whole-island label would read incorrectly as a
+ * locality address — "Philipsburg, Sint Maarten" is right because Philipsburg
+ * is on the Dutch side; "Philipsburg, Sint Maarten / Saint Martin" is not.
+ */
+export const VENUE_ISLAND_CARD_LABELS: Readonly> = {
+ saba: "Saba",
+ sxm: "Sint Maarten",
+ statia: "Statia",
+};
+
+/** Options for the admin venue editor's Island select (value + label pairs). */
+export const VENUE_ISLAND_OPTIONS: ReadonlyArray<{
+ value: VenueIsland;
+ label: string;
+}> = VENUE_ISLAND_KEYS.map((value) => ({
+ value,
+ label: VENUE_ISLAND_LABELS[value],
+}));
diff --git a/lib/where-to-buy-fixture.ts b/lib/where-to-buy-fixture.ts
index 29ee262..fac3f4a 100644
--- a/lib/where-to-buy-fixture.ts
+++ b/lib/where-to-buy-fixture.ts
@@ -3,10 +3,13 @@ import type { Beer, Venue } from "@/lib/types";
// Deterministic fixture records for the test-only /where-to-buy-fixture
// route used by the Playwright filtering suite. Shapes match the real
// Firestore models so VenueDirectory exercises the same code path as the
-// production page. The three Saba venues deliberately use different
-// locality strings (", Saba") so the suite covers the island
-// grouping regression from issue #116. Not real business data —
-// names/links are placeholders.
+// production page. Issue #134: most venues carry the canonical `island`
+// field with a locality-only `locationName` — Harbor Bar proves a "Philipsburg"
+// locality stays inside the Sint Maarten group instead of becoming an island
+// option. Fixture Quiet Cafe deliberately keeps the pre-#134 shape (no
+// `island`, island embedded in `locationName`) so the suite also covers the
+// transitional legacy fallback. Not real business data — names/links are
+// placeholders.
export const WHERE_TO_BUY_FIXTURE_BEERS: Beer[] = [
{
name: "Saba Suds Pilsner",
@@ -59,7 +62,8 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [
name: "Fixture Tavern",
slug: "fixture-tavern",
type: "bar_restaurant",
- locationName: "Fort Bay, Saba",
+ locationName: "Fort Bay",
+ island: "saba",
carriesBeerSlugs: ["saba-suds-pilsner", "fort-bay-ipa"],
tapBeerSlugs: ["saba-suds-pilsner"],
canBeerSlugs: ["fort-bay-ipa"],
@@ -74,7 +78,8 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [
name: "Fixture Bottle Shop",
slug: "fixture-bottle-shop",
type: "retail",
- locationName: "Windwardside, Saba",
+ locationName: "Windwardside",
+ island: "saba",
carriesBeerSlugs: ["fort-bay-ipa"],
tapBeerSlugs: [],
canBeerSlugs: ["fort-bay-ipa"],
@@ -88,7 +93,8 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [
name: "Fixture Harbor Bar",
slug: "fixture-harbor-bar",
type: "bar_restaurant",
- locationName: "SXM",
+ locationName: "Philipsburg",
+ island: "sxm",
carriesBeerSlugs: ["saba-suds-pilsner", "fort-bay-ipa"],
tapBeerSlugs: ["saba-suds-pilsner", "fort-bay-ipa"],
canBeerSlugs: [],
@@ -100,6 +106,8 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [
name: "Fixture Quiet Cafe",
slug: "fixture-quiet-cafe",
type: "bar_restaurant",
+ // Deliberately legacy: no `island` field — the record predates Issue
+ // #134, so grouping falls back to parsing `locationName`.
locationName: "Windwardside / The Bottom, Saba",
carriesBeerSlugs: ["saba-suds-pilsner"],
tapBeerSlugs: ["saba-suds-pilsner"],
@@ -116,7 +124,8 @@ export const WHERE_TO_BUY_FIXTURE_VENUES: Venue[] = [
name: "Fixture Empty Cantina",
slug: "fixture-empty-cantina",
type: "bar_restaurant",
- locationName: "Oranjestad, Statia",
+ locationName: "Oranjestad",
+ island: "statia",
carriesBeerSlugs: [],
tapBeerSlugs: [],
canBeerSlugs: [],
diff --git a/package.json b/package.json
index e9fb421..8918014 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"seed:venues": "tsx scripts/seed-venues.ts",
"optimize-assets": "node scripts/optimize-assets.mjs",
"bootstrap-superadmin": "node --env-file=.env.local --import tsx scripts/bootstrap-superadmin.ts",
- "prune:trade-leads": "node --env-file=.env.local --import tsx scripts/prune-trade-leads.ts"
+ "prune:trade-leads": "node --env-file=.env.local --import tsx scripts/prune-trade-leads.ts",
+ "migrate:venue-islands": "node --env-file=.env.local --import tsx scripts/migrate-venue-islands.ts"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
diff --git a/rules-tests/firestore.rules.test.ts b/rules-tests/firestore.rules.test.ts
index 1ef40e5..6c68f54 100644
--- a/rules-tests/firestore.rules.test.ts
+++ b/rules-tests/firestore.rules.test.ts
@@ -104,7 +104,38 @@ describe("beers and venues content writes", () => {
setDoc(doc(db, "beers", "new-beer"), { slug: "new-beer", name: "New", isPublic: true })
);
await assertSucceeds(
- setDoc(doc(db, "venues", "new-venue"), { slug: "new-venue", name: "Venue", isPublic: true })
+ setDoc(doc(db, "venues", "new-venue"), { slug: "new-venue", name: "Venue", island: "saba", isPublic: true })
+ );
+ });
+
+ it("requires a canonical island value on venue writes (Issue #134)", async () => {
+ await seedAdminRecord("admin1", "admin", "active");
+ const db = adminContext("admin1", "admin").firestore();
+
+ for (const island of ["saba", "sxm", "statia"]) {
+ await assertSucceeds(
+ setDoc(doc(db, "venues", `ok-${island}`), { slug: `ok-${island}`, island, isPublic: true })
+ );
+ }
+
+ // Missing, free-text, and locality-shaped islands are all rejected —
+ // "Philipsburg" must never be storable as an island again.
+ await assertFails(
+ setDoc(doc(db, "venues", "no-island"), { slug: "no-island", name: "V", isPublic: true })
+ );
+ await assertFails(
+ setDoc(doc(db, "venues", "bad-island"), { slug: "bad-island", island: "Philipsburg", isPublic: true })
+ );
+ await assertFails(
+ setDoc(doc(db, "venues", "bad-island-2"), { slug: "bad-island-2", island: "bonaire", isPublic: true })
+ );
+
+ // An update that keeps the merged doc's valid island is allowed.
+ await testEnv.withSecurityRulesDisabled(async (ctx) => {
+ await setDoc(doc(ctx.firestore(), "venues", "existing"), { slug: "existing", island: "sxm", isPublic: true });
+ });
+ await assertSucceeds(
+ updateDoc(doc(db, "venues", "existing"), { name: "Renamed" })
);
});
diff --git a/scripts/migrate-venue-islands.ts b/scripts/migrate-venue-islands.ts
new file mode 100644
index 0000000..4bef1c2
--- /dev/null
+++ b/scripts/migrate-venue-islands.ts
@@ -0,0 +1,190 @@
+/**
+ * Backfill the canonical `island` field on `venues` documents (Issue #134).
+ *
+ * Each legacy record encodes the island inside the free-text `locationName`
+ * (e.g. "Windwardside, Saba", "Sint Maarten"). The migration splits that into
+ * `{ island, locationName: locality }` via `resolveVenueGeography` in
+ * lib/venue-filters.ts. Records whose location can't be classified
+ * confidently — and records whose stored `island` disagrees with the parsed
+ * location — are reported for owner review and never overwritten.
+ *
+ * Usage:
+ * npm run migrate:venue-islands # dry run — prints the plan
+ * npm run migrate:venue-islands -- --write # apply the planned updates
+ *
+ * Prerequisites:
+ * - FIREBASE_ADMIN_* credentials in .env.local (loaded via --env-file)
+ * - Operator-level Firebase access
+ *
+ * Safety: dry-run is the default; writes require the explicit --write flag.
+ * The script is idempotent — already-migrated records are reported as
+ * unchanged and skipped. Output is limited to venue slugs/names/locations,
+ * which are public business data.
+ */
+
+import { cert, getApps, initializeApp, type App } from "firebase-admin/app";
+import { getFirestore } from "firebase-admin/firestore";
+import { resolveVenueGeography } from "@/lib/venue-filters";
+import { isVenueIsland } from "@/lib/venue-islands";
+import type { Venue } from "@/lib/types";
+
+const WRITE = process.argv.includes("--write");
+
+function getPrivateKey() {
+ const key = process.env.FIREBASE_ADMIN_PRIVATE_KEY;
+ if (!key) return "";
+ return key.replace(/\\n/g, "\n");
+}
+
+function getFirebaseAdminApp(): App {
+ const existing = getApps()[0];
+ if (existing) return existing;
+
+ const projectId =
+ process.env.FIREBASE_ADMIN_PROJECT_ID ??
+ process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID;
+ const clientEmail = process.env.FIREBASE_ADMIN_CLIENT_EMAIL;
+ const privateKey = getPrivateKey();
+
+ if (!projectId || !clientEmail || !privateKey) {
+ throw new Error(
+ "Missing Firebase Admin credentials. Set FIREBASE_ADMIN_PROJECT_ID, FIREBASE_ADMIN_CLIENT_EMAIL, and FIREBASE_ADMIN_PRIVATE_KEY."
+ );
+ }
+
+ return initializeApp({
+ credential: cert({ projectId, clientEmail, privateKey }),
+ });
+}
+
+interface PlannedUpdate {
+ slug: string;
+ island: string;
+ locality: string;
+}
+
+async function main() {
+ const db = getFirestore(getFirebaseAdminApp());
+ const snapshot = await db.collection("venues").orderBy("sortOrder", "asc").get();
+
+ console.log(
+ `${WRITE ? "WRITE" : "DRY-RUN"} — venue island migration (Issue #134)`
+ );
+ console.log(`Scanned ${snapshot.size} venue(s).`);
+
+ const updates: PlannedUpdate[] = [];
+ const conflicts: string[] = [];
+ const ambiguous: string[] = [];
+ let unchanged = 0;
+
+ for (const docSnap of snapshot.docs) {
+ const venue = docSnap.data() as Venue;
+ const resolved = resolveVenueGeography(venue.locationName);
+ const label = `${venue.name ?? docSnap.id} (${docSnap.id})`;
+
+ if (isVenueIsland(venue.island)) {
+ if (resolved && resolved.island !== venue.island) {
+ conflicts.push(
+ `${label}: stored island "${venue.island}" disagrees with locationName "${venue.locationName}" (resolves to "${resolved.island}")`
+ );
+ continue;
+ }
+ const locality = resolved ? resolved.locality : venue.locationName;
+ if (!resolved) {
+ // Island is already canonical; the location text is a pure locality
+ // the map doesn't know — safe to keep verbatim.
+ console.log(` = ${label}: island "${venue.island}", locality "${venue.locationName}" (kept as-is)`);
+ unchanged++;
+ continue;
+ }
+ if (venue.locationName === locality) {
+ console.log(` = ${label}: already migrated (island "${venue.island}", locality "${locality}")`);
+ unchanged++;
+ continue;
+ }
+ updates.push({ slug: docSnap.id, island: venue.island, locality });
+ console.log(
+ ` ~ ${label}: island "${venue.island}" kept; locationName "${venue.locationName}" → locality "${locality}"`
+ );
+ continue;
+ }
+
+ if (!resolved) {
+ ambiguous.push(`${label}: locationName "${venue.locationName}"`);
+ continue;
+ }
+
+ updates.push({ slug: docSnap.id, island: resolved.island, locality: resolved.locality });
+ console.log(
+ ` + ${label}: locationName "${venue.locationName}" → island "${resolved.island}", locality "${resolved.locality}"`
+ );
+ }
+
+ console.log(
+ `\nPlan: ${updates.length} to update, ${unchanged} unchanged, ${conflicts.length} conflict(s), ${ambiguous.length} ambiguous.`
+ );
+
+ for (const line of conflicts) {
+ console.log(` CONFLICT ${line}`);
+ }
+ for (const line of ambiguous) {
+ console.log(` AMBIGUOUS ${line}`);
+ }
+
+ if (conflicts.length > 0 || ambiguous.length > 0) {
+ console.log(
+ "\nConflicting/ambiguous records were NOT changed — review them with the owner and re-run."
+ );
+ }
+
+ if (!WRITE) {
+ if (updates.length > 0) {
+ console.log("\nDry run only — re-run with `--write` to apply these updates.");
+ }
+ return;
+ }
+
+ if (updates.length === 0) {
+ console.log("Nothing to write.");
+ return;
+ }
+
+ const collection = db.collection("venues");
+ let written = 0;
+ for (const update of updates) {
+ // Re-verify inside a transaction so a venue edited between scan and
+ // write keeps its newer island value rather than being overwritten.
+ const ref = collection.doc(update.slug);
+ const applied = await db.runTransaction(async (tx) => {
+ const fresh = await tx.get(ref);
+ const data = (fresh.data() ?? {}) as Venue;
+ const reResolved = resolveVenueGeography(data.locationName);
+ if (
+ !reResolved ||
+ reResolved.island !== update.island ||
+ reResolved.locality !== update.locality ||
+ (isVenueIsland(data.island) && data.island !== update.island)
+ ) {
+ return false;
+ }
+ tx.set(
+ ref,
+ { island: update.island, locationName: update.locality },
+ { merge: true }
+ );
+ return true;
+ });
+ if (applied) {
+ written++;
+ console.log(` wrote ${update.slug}`);
+ } else {
+ console.log(` skipped ${update.slug} (island changed since scan)`);
+ }
+ }
+ console.log(`Updated ${written} venue(s).`);
+}
+
+main().catch((err) => {
+ console.error("Migration failed:", err);
+ process.exit(1);
+});
diff --git a/scripts/venue-seed-data.json b/scripts/venue-seed-data.json
index acedd2a..a85cdb6 100644
--- a/scripts/venue-seed-data.json
+++ b/scripts/venue-seed-data.json
@@ -3,7 +3,7 @@
"name": "The Dive Bar",
"slug": "the-dive-bar",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 1,
@@ -13,13 +13,14 @@
"instagram": "https://www.instagram.com/thedivebarsaba",
"untappd": "https://untappd.com/v/the-dive-bar-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Tropics Cafe",
"slug": "tropics-cafe",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 2,
@@ -29,13 +30,14 @@
"instagram": "https://www.instagram.com/tropicscafesaba",
"untappd": "https://untappd.com/v/tropics-cafe-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "D'Corner",
"slug": "dcorner",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 3,
@@ -45,13 +47,14 @@
"instagram": "https://www.instagram.com/dcornersaba",
"untappd": "https://untappd.com/v/dcorner-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Amonhana",
"slug": "amonhana",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 4,
@@ -61,13 +64,14 @@
"instagram": "https://www.instagram.com/amohanhasaba",
"untappd": "https://untappd.com/v/amonhana-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Hideaway",
"slug": "hideaway",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 5,
@@ -77,13 +81,14 @@
"instagram": "https://www.instagram.com/hideawaysaba",
"untappd": "https://untappd.com/v/hideaway-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Island Flavor",
"slug": "island-flavor",
"type": "bar_restaurant",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 6,
@@ -93,13 +98,14 @@
"instagram": "https://www.instagram.com/islandflavorsaba",
"untappd": "https://untappd.com/v/island-flavor-saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Big Rock Supermarket",
"slug": "big-rock-supermarket",
"type": "retail",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 7,
@@ -107,13 +113,14 @@
"website": "https://example.com/big-rock-supermarket",
"facebook": "https://www.facebook.com/BigRockSupermarketSaba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Unique Supermarket",
"slug": "unique-supermarket",
"type": "retail",
- "locationName": "Saba",
+ "locationName": "",
"carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 8,
@@ -121,20 +128,22 @@
"website": "https://example.com/unique-supermarket",
"facebook": "https://www.facebook.com/UniqueSupermarketSaba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
},
{
"name": "Sea Saba",
"slug": "sea-saba",
"type": "retail",
- "locationName":"Saba",
- "carriesBeerSlugs":[],
+ "locationName": "",
+ "carriesBeerSlugs": [],
"isPublic": true,
"sortOrder": 9,
"links": {
"website": "https://seasaba.com",
"facebook": "https://www.facebook.com/Sea.Saba"
},
- "notesPublic": null
+ "notesPublic": null,
+ "island": "saba"
}
]
diff --git a/smoke-tests/where-to-buy-filters.spec.ts b/smoke-tests/where-to-buy-filters.spec.ts
index 4e2568e..19abda8 100644
--- a/smoke-tests/where-to-buy-filters.spec.ts
+++ b/smoke-tests/where-to-buy-filters.spec.ts
@@ -6,15 +6,16 @@ import { test, expect } from "./fixtures";
// also proves filtering works with analytics consent declined.
//
// Fixture data (lib/where-to-buy-fixture.ts):
-// Fixture Tavern Fort Bay, Saba tap: pilsner can: ipa
-// Fixture Bottle Shop Windwardside, Saba can: ipa
-// Fixture Harbor Bar SXM tap: pilsner + ipa
-// Fixture Quiet Cafe Windwardside / The Bottom, Saba tap: pilsner
-// Fixture Empty Cantina Oranjestad, Statia carries nothing — hidden (#130)
+// Fixture Tavern island saba, locality "Fort Bay" tap: pilsner can: ipa
+// Fixture Bottle Shop island saba, locality "Windwardside" can: ipa
+// Fixture Harbor Bar island sxm, locality "Philipsburg" tap: pilsner + ipa
+// Fixture Quiet Cafe LEGACY: no island, "Windwardside / The Bottom, Saba" tap: pilsner
+// Fixture Empty Cantina island statia, locality "Oranjestad" carries nothing — hidden (#130)
// Flat Point Amber is a public beer carried by no venue — never an option.
-// The three Saba venues deliberately use distinct ", Saba"
-// locationNames so this suite covers the island-grouping regression from
-// issue #116 (localities fragmenting into per-locality island groups).
+// Issue #134: island grouping/filtering reads the canonical `island` field;
+// Quiet Cafe deliberately lacks it so the suite also covers the transitional
+// legacy locationName fallback. Harbor Bar proves a "Philipsburg" locality
+// stays under Sint Maarten instead of becoming its own island option.
const ROUTE = "/where-to-buy-fixture";
@@ -129,6 +130,31 @@ test("island filter matches the canonical island", async ({ page }) => {
await expect(page).toHaveURL(/island=sxm/);
});
+test("a locality never becomes an island option; cards compose locality + island", async ({
+ page,
+}) => {
+ // Issue #134: Harbor Bar's locality is "Philipsburg" with canonical island
+ // "sxm" — the locality must not appear as a filter option, and the card
+ // shows the jurisdiction-aware "Philipsburg, Sint Maarten" (not the
+ // dual-island whole label).
+ await expect(
+ islandSelect(page).locator("option", { hasText: "Philipsburg" })
+ ).toHaveCount(0);
+ const sxmSection = page.locator("main section").filter({
+ has: page.getByRole("heading", {
+ name: "Sint Maarten / Saint Martin",
+ level: 2,
+ exact: true,
+ }),
+ });
+ await expect(
+ sxmSection.getByText("Philipsburg, Sint Maarten", { exact: true })
+ ).toBeVisible();
+ await expect(
+ sxmSection.getByText("Philipsburg, Sint Maarten / Saint Martin")
+ ).toHaveCount(0);
+});
+
test("Saba localities render under one Saba group and one filter option", async ({
page,
}) => {
diff --git a/tests/lib/venue-filters.test.ts b/tests/lib/venue-filters.test.ts
index 5efd6b4..5b0f959 100644
--- a/tests/lib/venue-filters.test.ts
+++ b/tests/lib/venue-filters.test.ts
@@ -10,9 +10,13 @@ import {
islandDisplayName,
islandKey,
parseVenueFilters,
+ resolveVenueGeography,
+ resolveVenueIsland,
sanitizeVenueFilters,
+ venueCardLocation,
venueCarriesBeer,
venueFiltersToSearch,
+ venueIslandKey,
venueIsStocked,
venueOffersFormat,
EMPTY_VENUE_FILTERS,
@@ -128,6 +132,212 @@ describe("islandKey / islandDisplayName", () => {
});
});
+describe("venueIslandKey (canonical field, Issue #134)", () => {
+ it("prefers the stored island over parsing locationName", () => {
+ // The locality must never determine grouping: "Philipsburg" parses to
+ // a non-island key, but the canonical field says sxm.
+ assert.equal(
+ venueIslandKey(venue({ island: "sxm", locationName: "Philipsburg" })),
+ "sxm"
+ );
+ assert.equal(
+ venueIslandKey(venue({ island: "saba", locationName: "Windwardside" })),
+ "saba"
+ );
+ // Even a locationName that would parse to a different island loses.
+ assert.equal(
+ venueIslandKey(venue({ island: "statia", locationName: "Saba" })),
+ "statia"
+ );
+ });
+
+ it("falls back to legacy locationName inference when island is absent", () => {
+ assert.equal(
+ venueIslandKey(venue({ locationName: "Windwardside, Saba" })),
+ "saba"
+ );
+ assert.equal(venueIslandKey(venue({ locationName: "SXM" })), "sxm");
+ });
+
+ it("ignores invalid stored island values", () => {
+ // A non-canonical stored value behaves as if the field were absent.
+ const legacy = venue({ locationName: "Fort Bay, Saba" });
+ assert.equal(
+ venueIslandKey({ ...legacy, island: "philipsburg" as never }),
+ "saba"
+ );
+ });
+});
+
+describe("resolveVenueIsland (admin editor preselection)", () => {
+ it("returns the stored island when valid", () => {
+ assert.equal(
+ resolveVenueIsland(venue({ island: "sxm", locationName: "anything" })),
+ "sxm"
+ );
+ });
+
+ it("derives the island from legacy locationName when unambiguous", () => {
+ assert.equal(
+ resolveVenueIsland(venue({ locationName: "Windwardside, Saba" })),
+ "saba"
+ );
+ assert.equal(resolveVenueIsland(venue({ locationName: "SXM" })), "sxm");
+ assert.equal(
+ resolveVenueIsland(venue({ locationName: "Oranjestad, Statia" })),
+ "statia"
+ );
+ });
+
+ it("returns undefined for a bare locality rather than guessing", () => {
+ assert.equal(
+ resolveVenueIsland(venue({ locationName: "Philipsburg" })),
+ undefined
+ );
+ assert.equal(
+ resolveVenueIsland(venue({ locationName: "Bonaire" })),
+ undefined
+ );
+ });
+});
+
+describe("resolveVenueGeography (migration mapping)", () => {
+ it("splits ', Saba' forms into island + locality", () => {
+ assert.deepEqual(resolveVenueGeography("Saba"), {
+ island: "saba",
+ locality: "",
+ });
+ assert.deepEqual(resolveVenueGeography("Windwardside, Saba"), {
+ island: "saba",
+ locality: "Windwardside",
+ });
+ assert.deepEqual(resolveVenueGeography("Fort Bay, Saba"), {
+ island: "saba",
+ locality: "Fort Bay",
+ });
+ assert.deepEqual(resolveVenueGeography("Windwardside / The Bottom, Saba"), {
+ island: "saba",
+ locality: "Windwardside / The Bottom",
+ });
+ });
+
+ it("maps SXM legacy spellings to sxm with an empty locality", () => {
+ for (const value of ["SXM", "Sint Maarten", "Saint Martin"]) {
+ assert.deepEqual(resolveVenueGeography(value), {
+ island: "sxm",
+ locality: "",
+ });
+ }
+ });
+
+ it("maps a bare Philipsburg locality to sxm, preserving the locality", () => {
+ assert.deepEqual(resolveVenueGeography("Philipsburg"), {
+ island: "sxm",
+ locality: "Philipsburg",
+ });
+ });
+
+ it("maps known bare Saba localities to saba", () => {
+ for (const locality of ["Windwardside", "The Bottom", "Fort Bay"]) {
+ assert.deepEqual(resolveVenueGeography(locality), {
+ island: "saba",
+ locality,
+ });
+ }
+ });
+
+ it("splits ', Statia' forms", () => {
+ assert.deepEqual(resolveVenueGeography("Oranjestad, Statia"), {
+ island: "statia",
+ locality: "Oranjestad",
+ });
+ });
+
+ it("returns null for values that cannot be classified confidently", () => {
+ // Bare "Oranjestad" is genuinely ambiguous (also Aruba's capital);
+ // unknown islands stay unresolved — never guessed.
+ assert.equal(resolveVenueGeography("Oranjestad"), null);
+ assert.equal(resolveVenueGeography("Sabana Grande"), null);
+ assert.equal(resolveVenueGeography("Bonaire"), null);
+ });
+
+ it("keeps the legacy Saba default for an empty location", () => {
+ assert.deepEqual(resolveVenueGeography(""), {
+ island: "saba",
+ locality: "",
+ });
+ assert.deepEqual(resolveVenueGeography(undefined), {
+ island: "saba",
+ locality: "",
+ });
+ });
+});
+
+describe("venueCardLocation", () => {
+ it("composes locality + short island label for migrated venues", () => {
+ assert.equal(
+ venueCardLocation(venue({ island: "saba", locationName: "Windwardside" })),
+ "Windwardside, Saba"
+ );
+ assert.equal(
+ venueCardLocation(venue({ island: "saba", locationName: "Fort Bay" })),
+ "Fort Bay, Saba"
+ );
+ // Jurisdiction-aware: the dual-label is wrong as a locality address.
+ assert.equal(
+ venueCardLocation(venue({ island: "sxm", locationName: "Philipsburg" })),
+ "Philipsburg, Sint Maarten"
+ );
+ assert.equal(
+ venueCardLocation(venue({ island: "statia", locationName: "Oranjestad" })),
+ "Oranjestad, Statia"
+ );
+ });
+
+ it("shows only the whole-island label when locality is blank or redundant", () => {
+ assert.equal(
+ venueCardLocation(venue({ island: "saba", locationName: "" })),
+ "Saba"
+ );
+ assert.equal(
+ venueCardLocation(venue({ island: "saba", locationName: "Saba" })),
+ "Saba"
+ );
+ assert.equal(
+ venueCardLocation(venue({ island: "sxm", locationName: "" })),
+ "Sint Maarten / Saint Martin"
+ );
+ });
+
+ it("does not double the island when locality already ends with it", () => {
+ // Admin typed the old combined format into the locality field — the card
+ // shows it as-is instead of "Windwardside, Saba, Saba".
+ assert.equal(
+ venueCardLocation(
+ venue({ island: "saba", locationName: "Windwardside, Saba" })
+ ),
+ "Windwardside, Saba"
+ );
+ assert.equal(
+ venueCardLocation(
+ venue({ island: "sxm", locationName: "Philipsburg, Sint Maarten" })
+ ),
+ "Philipsburg, Sint Maarten"
+ );
+ });
+
+ it("shows the raw locationName for legacy records without island", () => {
+ assert.equal(
+ venueCardLocation(venue({ locationName: "Windwardside, Saba" })),
+ "Windwardside, Saba"
+ );
+ assert.equal(
+ venueCardLocation(venue({ locationName: "SXM" })),
+ "SXM"
+ );
+ });
+});
+
describe("venueCarriesBeer / venueOffersFormat", () => {
const v = venue({
carriesBeerSlugs: ["pilsner"],
@@ -164,10 +374,11 @@ describe("venueCarriesBeer / venueOffersFormat", () => {
});
describe("filterVenues", () => {
- // Tavern: Fort Bay, Saba — pilsner on tap + ipa in can
- // Bottle Shop: Windwardside, Saba — ipa in can
- // Harbor Bar: SXM — pilsner + ipa on tap
- // Quiet Cafe: Windwardside / The Bottom, Saba — pilsner on tap
+ // Tavern: island saba, locality Fort Bay — pilsner on tap + ipa in can
+ // Bottle Shop: island saba, locality Windwardside — ipa in can
+ // Harbor Bar: island sxm, locality Philipsburg — pilsner + ipa on tap
+ // Quiet Cafe: LEGACY record — no island; locationName "Windwardside /
+ // The Bottom, Saba" — pilsner on tap
// (Empty Cantina — Statia, no beer — is excluded up front by
// venueIsStocked, mirroring the page layer; see the describe below.)
const venues = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked);
@@ -228,11 +439,19 @@ describe("filterVenues", () => {
);
});
- it("island filter uses canonical keys (SXM matches locationName variants)", () => {
+ it("island filter uses the canonical island field, not the locality", () => {
+ // Harbor Bar's locality is "Philipsburg" — the island=sxm filter must
+ // match it via the canonical field, and "philipsburg" is not a filter.
assert.deepEqual(
names(filterVenues(venues, { ...EMPTY_VENUE_FILTERS, island: "sxm" })),
["fixture-harbor-bar"]
);
+ assert.deepEqual(
+ names(
+ filterVenues(venues, { ...EMPTY_VENUE_FILTERS, island: "philipsburg" })
+ ),
+ []
+ );
});
it("island saba returns every Saba venue regardless of locality", () => {
@@ -314,6 +533,21 @@ describe("distinctIslands / groupVenuesByIsland", () => {
["fixture-tavern", "fixture-bottle-shop", "fixture-quiet-cafe"]
);
});
+
+ it("a locality can never become an island option or group (Issue #134)", () => {
+ // Harbor Bar's locationName is now a pure locality ("Philipsburg") with
+ // island "sxm" — neither the option list nor the headings may contain it.
+ const venues = WHERE_TO_BUY_FIXTURE_VENUES.filter(venueIsStocked);
+ const islands = distinctIslands(venues);
+ assert.equal(islands.includes("philipsburg"), false);
+ assert.equal(islands.includes("windwardside"), false);
+ assert.equal(islands.includes("fort bay"), false);
+ assert.deepEqual(islands, ["saba", "sxm"]);
+ assert.deepEqual(
+ groupVenuesByIsland(venues).map((g) => g.key),
+ ["saba", "sxm"]
+ );
+ });
});
describe("carriedBeerOptions", () => {
diff --git a/tests/lib/venue-islands.test.ts b/tests/lib/venue-islands.test.ts
new file mode 100644
index 0000000..c4eefa8
--- /dev/null
+++ b/tests/lib/venue-islands.test.ts
@@ -0,0 +1,62 @@
+import { describe, it } from "node:test";
+import assert from "node:assert";
+import {
+ isVenueIsland,
+ VENUE_ISLAND_CARD_LABELS,
+ VENUE_ISLAND_KEYS,
+ VENUE_ISLAND_LABELS,
+ VENUE_ISLAND_OPTIONS,
+} from "../../lib/venue-islands";
+
+describe("venue island vocabulary (Issue #134)", () => {
+ it("exposes exactly the supported canonical keys", () => {
+ assert.deepEqual([...VENUE_ISLAND_KEYS], ["saba", "sxm", "statia"]);
+ });
+
+ it("validates canonical keys and rejects anything else", () => {
+ for (const key of VENUE_ISLAND_KEYS) {
+ assert.equal(isVenueIsland(key), true, key);
+ }
+ for (const bad of [
+ "Saba",
+ "SXM",
+ "Philipsburg",
+ "Windwardside",
+ "bonaire",
+ "",
+ "constructor",
+ 0,
+ null,
+ undefined,
+ ]) {
+ assert.equal(isVenueIsland(bad), false, String(bad));
+ }
+ });
+
+ it("maps each key to its whole-island public label", () => {
+ assert.equal(VENUE_ISLAND_LABELS.saba, "Saba");
+ assert.equal(VENUE_ISLAND_LABELS.sxm, "Sint Maarten / Saint Martin");
+ assert.equal(VENUE_ISLAND_LABELS.statia, "Sint Eustatius / Statia");
+ // "sxm" is internal-only — it must never surface as a public label.
+ for (const label of Object.values(VENUE_ISLAND_LABELS)) {
+ assert.equal(label.includes("SXM"), false, label);
+ }
+ });
+
+ it("uses short jurisdiction labels for card locality composition", () => {
+ assert.equal(VENUE_ISLAND_CARD_LABELS.saba, "Saba");
+ assert.equal(VENUE_ISLAND_CARD_LABELS.sxm, "Sint Maarten");
+ assert.equal(VENUE_ISLAND_CARD_LABELS.statia, "Statia");
+ });
+
+ it("provides admin select options keyed by canonical value", () => {
+ assert.deepEqual(
+ VENUE_ISLAND_OPTIONS.map((o) => [o.value, o.label]),
+ [
+ ["saba", "Saba"],
+ ["sxm", "Sint Maarten / Saint Martin"],
+ ["statia", "Sint Eustatius / Statia"],
+ ]
+ );
+ });
+});
diff --git a/tests/security-rules.test.ts b/tests/security-rules.test.ts
index d2e17f1..1b5e421 100644
--- a/tests/security-rules.test.ts
+++ b/tests/security-rules.test.ts
@@ -65,6 +65,15 @@ describe("firestore.rules", () => {
"Expected public document check for beers/venues"
);
});
+
+ it("requires a canonical island on venue writes (Issue #134)", () => {
+ assert.ok(
+ rules.includes(
+ "request.resource.data.island in ['saba', 'sxm', 'statia']"
+ ),
+ "Expected the venue island allowlist on writes"
+ );
+ });
});
describe("storage.rules", () => {