feat: OSM-sourced visitor info — hours, accessibility, fee (#7)#439
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three optional POI fields mapped to OpenStreetMap tags and surface them in the sidebar Visitor Information section: - opening_hours (humanized for display, raw-string fallback) - wheelchair -> Accessibility (Accessible/Limited/Not accessible/Designated) - fee -> Fee (Yes/No/Varies) Schema: migration 067 adds the columns + CHECK constraints. Fields are admin-editable on every POI (admin.js allowlists, EditView inputs) and returned by all POI read endpoints. OSM enrichment (two sources, both idempotent + non-clobbering via COALESCE): - import-osm-amenities.js: amenities.json snapshot now carries the three tags (35 wheelchair, 17 fee across 240 amenities). - generate-osm-matches.js + poi-osm-matches.json + apply-osm-matches.js: matches ~248 curated POIs to OSM by name similarity + confidence-scaled proximity (high precision; proximity alone never matches). 204 linked with osm_id, 20 rows enriched. Matches are committed and reviewable. Renumbered spec 028 -> 029 to avoid collision with the merged PWA spec. Closes #7 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements the OSM-sourced visitor information feature (operating hours, wheelchair accessibility, and fee flags) across the database schema, backend APIs, OSM import/matching pipelines, and frontend views. The feedback suggests sanitizing incoming OpenStreetMap tags in the importer to prevent database constraint violations, optimizing the match application script to avoid redundant writes and spurious timestamp updates, ensuring schema consistency in server.js, and refining the frontend rendering logic to prevent empty rows when hours contain only whitespace.
| [name, feature.lat, feature.lon, meta.activity, description, feature.osm_id, moreInfoLink, | ||
| feature.opening_hours || null, feature.wheelchair || null, feature.fee || null] |
There was a problem hiding this comment.
The database schema defines strict CHECK constraints on the wheelchair and fee columns. However, import-osm-amenities.js does not validate or sanitize these values before inserting them. Since OpenStreetMap is crowd-sourced, amenities.json could contain unexpected values (e.g., wheelchair=accessible or fee=donation), which would cause the import script to crash with a constraint violation.
We should sanitize these values to match the allowed database values before executing the query.
[name, feature.lat, feature.lon, meta.activity, description, feature.osm_id, moreInfoLink,
feature.opening_hours || null,
['yes', 'limited', 'no', 'designated'].includes(feature.wheelchair?.toLowerCase()) ? feature.wheelchair.toLowerCase() : null,
['yes', 'no', 'conditional'].includes(feature.fee?.toLowerCase()) ? feature.fee.toLowerCase() : null]| const fill = await pool.query( | ||
| `UPDATE pois SET | ||
| opening_hours = COALESCE(opening_hours, $2), | ||
| wheelchair = COALESCE(wheelchair, $3), | ||
| fee = COALESCE(fee, $4), | ||
| updated_at = CURRENT_TIMESTAMP | ||
| WHERE name = $1 AND deleted IS NOT TRUE`, | ||
| [m.poi_name, m.opening_hours, m.wheelchair, m.fee] | ||
| ); | ||
| if (fill.rowCount === 0) { missing++; continue; } | ||
| if (m.opening_hours || m.wheelchair || m.fee) enriched += fill.rowCount; |
There was a problem hiding this comment.
The current implementation of apply-osm-matches.js runs on every deploy and updates the updated_at timestamp for all matched POIs, even if no data has actually changed. This causes unnecessary database writes and spurious timestamp updates.
To optimize this, we can first check if the POI exists, and then only execute the UPDATE statement if there is actually new information to add (i.e., at least one of the fields is currently NULL and the match has a non-null value).
const exists = await pool.query(
'SELECT 1 FROM pois WHERE name = $1 AND deleted IS NOT TRUE LIMIT 1',
[m.poi_name]
);
if (exists.rowCount === 0) { missing++; continue; }
const fill = await pool.query(
'UPDATE pois SET opening_hours = COALESCE(opening_hours, $2), wheelchair = COALESCE(wheelchair, $3), fee = COALESCE(fee, $4), updated_at = CURRENT_TIMESTAMP WHERE name = $1 AND deleted IS NOT TRUE AND ((opening_hours IS NULL AND $2 IS NOT NULL) OR (wheelchair IS NULL AND $3 IS NOT NULL) OR (fee IS NULL AND $4 IS NOT NULL))',
[m.poi_name, m.opening_hours, m.wheelchair, m.fee]
);
if (fill.rowCount > 0 && (m.opening_hours || m.wheelchair || m.fee)) {
enriched += fill.rowCount;
}| -- OSM-sourced visitor info (#7); see migration 067 for CHECK constraints | ||
| opening_hours TEXT, | ||
| wheelchair VARCHAR(12), | ||
| fee VARCHAR(12), |
There was a problem hiding this comment.
To ensure consistency between the initial database schema creation and the migrations, the CHECK constraints for wheelchair and fee should be defined directly in the CREATE TABLE statement in server.js as well.
| -- OSM-sourced visitor info (#7); see migration 067 for CHECK constraints | |
| opening_hours TEXT, | |
| wheelchair VARCHAR(12), | |
| fee VARCHAR(12), | |
| -- OSM-sourced visitor info (#7) | |
| opening_hours TEXT, | |
| wheelchair VARCHAR(12) CHECK (wheelchair IS NULL OR wheelchair IN ('yes','limited','no','designated')), | |
| fee VARCHAR(12) CHECK (fee IS NULL OR fee IN ('yes','no','conditional')), | |
| <CellSignal level={destination.cell_signal} /> | ||
| </div> | ||
| )} | ||
| {destination.opening_hours && ( |
There was a problem hiding this comment.
If destination.opening_hours contains only whitespace, it will still be evaluated as truthy and render an empty 'Hours' row in the UI, violating the acceptance criteria of hiding empty rows. We should check if the trimmed string is non-empty before rendering.
| {destination.opening_hours && ( | |
| {destination.opening_hours && destination.opening_hours.trim() && ( |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implements issue #7 (Cost / Hours / Mobility fields) by adding three optional POI fields mapped to standard OpenStreetMap tags, surfaced in the sidebar Visitor Information section and editable on every POI:
opening_hoursopening_hourswheelchairwheelchairfeefeeCost is captured as a yes/no fee flag, not a dollar amount — OSM doesn't reliably carry pricing (the issue itself deferred it).
Schema & API
067_osm_visitor_info.sql— 3 columns + idempotent CHECK constraints.admin.jsallowlists (4 sites) +EditViewinputs make all three editable on any POI./api/pois,/api/destinations,/api/linear-features+:id) return the fields.OSM enrichment (idempotent, non-clobbering via COALESCE)
amenities.jsonsnapshot now carries the tags — 35 wheelchair, 17 fee across 240 restrooms/playgrounds.generate-osm-matches.jsmatches ~248 of 465 curated POIs (zoos, visitor centers, parks, businesses) to OSM by name similarity + confidence-scaled proximity (proximity alone never matches). Results are committed/reviewable inpoi-osm-matches.json;apply-osm-matches.jslinks 204 withosm_idand enriches 20 rows.Notes
osm_ids mean future community OSM edits flow in on the nextapply-osm-matches.jsrun.028 → 029to avoid collision with the merged PWA spec.Closes #7
Test plan
./run.sh buildpassesimport-osm-amenities.js+apply-osm-matches.json prod (manual deploy steps)🤖 Generated with Claude Code