Skip to content

feat: store normalized Chattanooga routes in Payload - #115

Merged
kwiens merged 10 commits into
mainfrom
codex/chattanooga-route-topology
Sep 20, 2026
Merged

kwiens merged 10 commits into
mainfrom
codex/chattanooga-route-topology

Conversation

@kwiens

@kwiens kwiens commented Sep 19, 2026

Copy link
Copy Markdown
Owner

🤖

What this changes

This moves the Casual route catalog into Payload/Postgres and makes the database the source of truth for which routes exist, how they are presented, and where their geometry comes from.

It adds Routes as a first-class collection under Payload's Map content admin section, alongside Trails. Every published Route appears in the Casual tab. A Trail does not appear there automatically; an editor exposes one by creating a Route whose geometry source is Existing trail and selecting that Trail. This keeps Casual as one ordered, editable list without duplicating Trail geometry or making the client merge two collections.

Generated normalized GIS is not committed. Importers normalize verified source data and write the result, measurements, and provenance directly to Postgres.

Route vs. Trail

A Trail is the canonical trail record used by the trail experience: trail complex, rating/type, steward, geometry, and elevation data.

A Route is the curated item shown in Casual mode. It owns the public label, description, styling, direction controls, and stable route ID. Its line can come from one of three explicit sources:

Geometry source Geometry owner Public rendering Intended use
imported Route record in Postgres Shared route GeoJSON layers Verified GIS or existing committed city geometry
trail Linked Trail record Shared route GeoJSON layers Put an existing Trail, such as Deschutes River Trail, in Casual without copying its line
studio Named Mapbox Studio layer Existing Studio layer Explicit transition state for current geometry that has not yet been imported

There is no implicit Studio fallback. If a Route is database-backed (imported or trail), its same-named Studio layer is hidden even if the route API is unavailable. A Studio layer is shown only when a published Route explicitly declares Studio as its geometry source.

End-to-end data flow

Imported route

verified source → normalization to WGS84/topology cleanup → Payload Route → Postgres → /api/map/routes → shared Mapbox GeoJSON source

The Route owns its geometry, display bounds/measurements, and import provenance: source path, SHA-256, and source feature count.

Trail-backed route

Payload Route → selected same-city published Trail → current Trail geometry/measurements → /api/map/routes → shared Mapbox GeoJSON source

The Route stores only the relationship. The read path resolves the Trail's current geometry, distance, and bounds, so later Trail edits automatically flow through to Casual. On first save, a blank Route name and route ID derive from the Trail name and slug. Its kind is forced to trail.

Studio-backed route

Payload Route metadata → explicitly named known Chattanooga Studio layer

The database still controls whether the item exists in Casual and supplies its card metadata. The geometry stays in Studio until a verified current source is available. Studio-backed Routes are intentionally omitted from /api/map/routes.

Public app behavior

The server resolves the active city from the request hostname, reads published Routes through the Payload Local API, and passes that list to the client. A valid ?city=bend or ?city=chattanooga query overrides hostname and environment selection across the map, About, embed, export, and SVG redirect flows; invalid ids fall back to the normal resolution path. The same database-backed list drives:

  • the Casual tab and route selection
  • visible route layers
  • URL/deep-link route lookup
  • the About-page and embed route picker
  • route exports

The route API returns a FeatureCollection containing only drawable imported and trail-backed Routes. Runtime code renders those from one shared GeoJSON source and renders Studio-backed Routes from their explicitly configured Studio layers.

This removes the checked-in TypeScript route arrays as the runtime catalog: an absent or unpublished database Route is not silently restored from static metadata.

Admin behavior and validation

Routes are versioned Payload documents with drafts and up to 50 revisions. Publishing enforces source-specific integrity:

  • every published Route needs a city, stable route ID, and name
  • the pair of city and route ID must be unique
  • an imported Route needs drawable normalized geometry plus import provenance
  • a trail-backed Route needs a published, same-city Trail with drawable geometry
  • a Studio Route must reference one of the known Chattanooga Studio route IDs

The Trail relationship picker is filtered to published Trails in the Route's selected city. Display fields include kind (ride, greenway, path, or trail), description, color, width, opacity, distance, route bounds, arrow controls, and optional reverse-arrow bounds.

Database schema

The schema is introduced by migration 20260919_215743 and expanded by 20260919_224403.

CREATE TYPE enum_routes_city
  AS ENUM ('chattanooga', 'bend');

CREATE TYPE enum_routes_status
  AS ENUM ('draft', 'published');

CREATE TYPE enum_routes_kind
  AS ENUM ('ride', 'greenway', 'path', 'trail');

CREATE TYPE enum_routes_geometry_source
  AS ENUM ('imported', 'trail', 'studio');

CREATE TABLE routes (
  id                    serial PRIMARY KEY,
  name                  varchar,
  city                  enum_routes_city,
  route_id              varchar,
  kind                  enum_routes_kind DEFAULT 'ride',
  geometry_source       enum_routes_geometry_source DEFAULT 'imported',
  source_trail_id       integer REFERENCES trails(id) ON DELETE SET NULL,

  description           varchar,
  color                 varchar DEFAULT '#2563EB',
  default_width         numeric DEFAULT 8,
  opacity               numeric DEFAULT 1,
  distance              numeric,
  bounds                jsonb,
  reverse_arrow_bounds  jsonb,
  hide_arrows           boolean DEFAULT false,
  reverse_direction     boolean DEFAULT false,

  geom                  jsonb,
  source_path           varchar,
  source_sha256         varchar,
  source_feature_count  numeric,

  _status               enum_routes_status DEFAULT 'draft',
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX "city_routeId_idx"
  ON routes (city, route_id);

CREATE INDEX routes_route_id_idx
  ON routes (route_id);
CREATE INDEX routes_source_trail_idx
  ON routes (source_trail_id);
CREATE INDEX routes__status_idx
  ON routes (_status);
CREATE INDEX routes_created_at_idx
  ON routes (created_at);
CREATE INDEX routes_updated_at_idx
  ON routes (updated_at);

Payload also creates _routes_v, which mirrors the Route fields as version-prefixed columns and links each revision back to routes.id. It includes indexes for parent, route ID, status, timestamps, latest revision, and the city/route-ID pair. The new routes relationship in payload_locked_documents_rels supports Payload document locking.

Several SQL columns remain nullable because Payload must be able to save drafts. The publish hook supplies the conditional invariants that SQL alone cannot express—for example, requiring geometry for imported Routes, a source Trail for trail-backed Routes, and a known layer ID for Studio-backed Routes.

Current city rollout

Bend

The production seed upserts all eight existing Casual routes as imported, using the already committed public/data/bend/bike-network.geojson as its source. The redundant generated public/data/bend/routes.geojson is removed.

The existing Deschutes River Trail Casual Route remains imported for now because its current Casual line covers a different section than the MTB Trail record. An editor can deliberately switch it, or any future Casual entry, to an existing Trail in admin.

Chattanooga

The Riverwalk importer reads the checksum-verified shapefile from the archived PR #67 GIS bundle, transforms it from NAD83 / UTM zone 16N to WGS84, normalizes segment direction/topology in memory, and upserts the result into Payload:

pnpm db:import:chattanooga-routes "/path/to/GIS/Uncompressed files"

The normalized output is written directly to the database and is not added to Git. Riverwalk is treated as runtime-owned even before that manual import, so the older same-named Studio line cannot reappear as a fallback.

The other five Chattanooga Casual routes have newer Studio geometry than the archived source files. The production seed therefore creates explicit Studio Route records for their metadata and visibility. It preserves any route an editor has already migrated to imported or trail-backed geometry.

Deploy and operations

The production build pipeline runs, in order:

  1. pnpm db:migrate
  2. pnpm db:seed:bend-routes
  3. pnpm db:seed:chattanooga-routes
  4. pnpm build

Vercel preview/development builds skip migrations and seeds because they share the production database. Non-Vercel builds with DATABASE_URL keep the same migrate-then-seed behavior.

Both seeds are idempotent and skip unchanged records. They do not overwrite intentional source migrations. The Chattanooga Riverwalk GIS import remains a separate manual operation because its external source archive is not committed and therefore is not available during deploy.

Validation

  • pnpm check
  • pnpm test:run — 66 files / 678 tests
  • production build with Payload/Postgres
  • production migrations applied; seeds are idempotent on the live database
  • production route audit matched every former catalog field and found valid drawable geometry/provenance: Chattanooga has six published Routes (one imported Riverwalk plus five Studio), and Bend has eight imported Routes
  • both migrations exercised down and back up against local Postgres
  • Bend seed verified with eight imported Routes and an idempotent rerun
  • Chattanooga seed verified with five Studio Routes and an idempotent rerun
  • local API/page smoke test verified that Studio Routes stay out of route GeoJSON while remaining in the server-rendered Casual list
  • Chattanooga importer dry-run exercised against the archived PR GIS files from mapbox #67 Riverwalk shapefile
  • all PR checks and the Vercel deployment are green

@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
bikemap Ready Ready Preview Sep 20, 2026 1:17pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-19T19:17:45.421726Z cdbb46e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kwiens

kwiens commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: cdbb46edc0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…te-topology

# Conflicts:
#	docs/guides/chattanooga-gis-import.md
#	src/components/Map.tsx
#	src/data/geo_data.ts
#	src/utils/map.ts
@kwiens kwiens changed the title fix: normalize multipart route directions feat: store normalized Chattanooga routes in Payload Sep 19, 2026
@kwiens
kwiens changed the base branch from codex/pr100-gis-import to main September 19, 2026 22:16
…te-topology

# Conflicts:
#	src/migrations/index.ts
@kwiens
kwiens merged commit 582fe5f into main Sep 20, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview c9d020a6 Deployed Sep 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant