From 9a95b0cbffa9b30d1245f349c246b95702a16e80 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Thu, 24 Sep 2026 10:31:06 -0400 Subject: [PATCH] Separate venue island from locality in admin and public grouping (#134) The free-text locationName field doubled as island identity and locality, which let localities like "Philipsburg" or "Windwardside" surface as public island groups. Venues now carry a canonical `island` field (saba | sxm | statia) that drives /where-to-buy grouping, the island filter, and the card's composed "locality, island" line; `locationName` is now a pure free-text locality. Reads keep the legacy locationName inference as a transitional fallback until the migrate:venue-islands backfill runs. - lib/venue-islands.ts: canonical key/label/options vocabulary shared by the admin select, rules allowlist, and public display - Admin venue form: required Island select + separate locality input; legacy records preselect the inferred island; save rejects non-canonical values - firestore.rules: venue create/update requires a canonical island - scripts/migrate-venue-islands.ts: dry-run/--write backfill; ambiguous or conflicting records are reported, never guessed - Venue card: "Philipsburg, Sint Maarten"-style jurisdiction-aware display; analytics island param is now the stable canonical key Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 1 + components/admin-dashboard.tsx | 13 +- components/admin-fixture.tsx | 10 +- components/admin-workspace.tsx | 21 +- components/venue-card.tsx | 7 +- docs/TECHNICAL.md | 23 ++- docs/admin/managing-locations.md | 37 ++-- firestore.rules | 11 +- lib/types.ts | 8 + lib/venue-filters.ts | 162 ++++++++++++--- lib/venue-islands.ts | 50 +++++ lib/where-to-buy-fixture.ts | 25 ++- package.json | 3 +- rules-tests/firestore.rules.test.ts | 33 ++- scripts/migrate-venue-islands.ts | 190 ++++++++++++++++++ scripts/venue-seed-data.json | 47 +++-- smoke-tests/where-to-buy-filters.spec.ts | 42 +++- tests/lib/venue-filters.test.ts | 244 ++++++++++++++++++++++- tests/lib/venue-islands.test.ts | 62 ++++++ tests/security-rules.test.ts | 9 + 20 files changed, 895 insertions(+), 103 deletions(-) create mode 100644 lib/venue-islands.ts create mode 100644 scripts/migrate-venue-islands.ts create mode 100644 tests/lib/venue-islands.test.ts diff --git a/README.md b/README.md index 7e101a0..3a08d7b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Never commit real values to source control. `.env.local` is already ignored by G | `npm run seed:venues` | Seed sample venue data (developer script) | | `npm run bootstrap-superadmin` | Grant the initial superadmin role to `SUPER_ADMIN_EMAIL` (see docs first) | | `npm run prune:trade-leads` | Prune trade inquiries past the 24-month retention window (dry-run; `-- --delete` executes) | +| `npm run migrate:venue-islands` | Backfill the canonical venue `island` field from legacy `locationName` values (dry-run; `-- --write` executes) | ## Deployment overview diff --git a/components/admin-dashboard.tsx b/components/admin-dashboard.tsx index ec407ad..d7ad444 100644 --- a/components/admin-dashboard.tsx +++ b/components/admin-dashboard.tsx @@ -30,6 +30,8 @@ import { type RebuildMeta, } from "@/components/admin-workspace"; import { refreshAdminAccess } from "@/lib/admin-session-refresh"; +import { resolveVenueIsland } from "@/lib/venue-filters"; +import { isVenueIsland } from "@/lib/venue-islands"; import type { AdminRole, Beer, Venue } from "@/lib/types"; const DEFAULT_BEER: Beer = { @@ -195,7 +197,11 @@ export function AdminDashboard() { useEffect(() => { const found = venues.find((venue) => venue.slug === selectedVenueSlug); if (!found) return; - setVenueForm(found); + // Legacy records lack the canonical island field (Issue #134): preselect + // the island resolved from the old free-text location so saving writes + // the field without manual repair. Unresolvable records leave the + // required select unset instead of guessing. + setVenueForm({ ...found, island: resolveVenueIsland(found) }); setVenueCarriesSelection(found.carriesBeerSlugs ?? []); setVenueTapSelection(found.tapBeerSlugs ?? []); setVenueCanSelection(found.canBeerSlugs ?? []); @@ -538,6 +544,11 @@ export function AdminDashboard() { return; } + if (!isVenueIsland(venueForm.island)) { + setStatusMessage("Select an island for this venue."); + return; + } + setIsSaving(true); setStatusMessage(""); try { diff --git a/components/admin-fixture.tsx b/components/admin-fixture.tsx index 4ca9f85..b6cb258 100644 --- a/components/admin-fixture.tsx +++ b/components/admin-fixture.tsx @@ -11,6 +11,8 @@ import { AdminWorkspace, type RebuildMeta, } from "@/components/admin-workspace"; +import { resolveVenueIsland } from "@/lib/venue-filters"; +import { isVenueIsland } from "@/lib/venue-islands"; import type { AdminRole, Beer, Venue } from "@/lib/types"; const FIXTURE_BEERS: Beer[] = [ @@ -50,6 +52,7 @@ const FIXTURE_VENUES: Venue[] = [ slug: "fixture-tavern", type: "bar_restaurant", locationName: "Windwardside", + island: "saba", carriesBeerSlugs: ["saba-suds-pilsner"], tapBeerSlugs: ["saba-suds-pilsner"], canBeerSlugs: [], @@ -143,7 +146,8 @@ export function AdminFixture({ role }: { role: AdminRole }) { setSelectedVenueSlug(slug); const found = FIXTURE_VENUES.find((venue) => venue.slug === slug); if (found) { - setVenueForm(found); + // Mirror the real dashboard's legacy-island resolution (Issue #134). + setVenueForm({ ...found, island: resolveVenueIsland(found) }); setVenueCarriesSelection(found.carriesBeerSlugs ?? []); setVenueTapSelection(found.tapBeerSlugs ?? []); setVenueCanSelection(found.canBeerSlugs ?? []); @@ -169,6 +173,10 @@ export function AdminFixture({ role }: { role: AdminRole }) { setStatusMessage("Venue name and slug are required."); return; } + if (!isVenueIsland(venueForm.island)) { + setStatusMessage("Select an island for this venue."); + return; + } setIsSaving(true); setStatusMessage(""); setTimeout(() => { diff --git a/components/admin-workspace.tsx b/components/admin-workspace.tsx index 3e75617..e7625b8 100644 --- a/components/admin-workspace.tsx +++ b/components/admin-workspace.tsx @@ -8,6 +8,7 @@ import { type AdminPanelUser, } from "@/components/admin-access"; import type { Beer, Venue } from "@/lib/types"; +import { VENUE_ISLAND_OPTIONS, type VenueIsland } from "@/lib/venue-islands"; export interface RebuildMeta { cooldownUntil?: number; @@ -383,8 +384,24 @@ export function AdminWorkspace({ +