Skip to content

feat: OSM-sourced visitor info — hours, accessibility, fee (#7)#439

Merged
fatherlinux merged 3 commits into
masterfrom
feature/7-osm-visitor-info
May 29, 2026
Merged

feat: OSM-sourced visitor info — hours, accessibility, fee (#7)#439
fatherlinux merged 3 commits into
masterfrom
feature/7-osm-visitor-info

Conversation

@fatherlinux
Copy link
Copy Markdown
Member

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:

Field Column OSM tag Display
Hours opening_hours opening_hours humanized (e.g. Daily 9:30am–5pm), raw-string fallback for complex rules
Accessibility wheelchair wheelchair Accessible / Limited / Not accessible / Designated
Fee fee fee Yes / No / Varies

Cost 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

  • Migration 067_osm_visitor_info.sql — 3 columns + idempotent CHECK constraints.
  • admin.js allowlists (4 sites) + EditView inputs make all three editable on any POI.
  • All POI read endpoints (/api/pois, /api/destinations, /api/linear-features + :id) return the fields.

OSM enrichment (idempotent, non-clobbering via COALESCE)

  • Amenities: amenities.json snapshot now carries the tags — 35 wheelchair, 17 fee across 240 restrooms/playgrounds.
  • Curated POIs: generate-osm-matches.js matches ~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 in poi-osm-matches.json; apply-osm-matches.js links 204 with osm_id and enriches 20 rows.

Notes

  • Honest ceiling: matching is solved (248), but OSM only tags these fields on a minority of CVNP features, so ~53 POIs gain visible data today. Linked osm_ids mean future community OSM edits flow in on the next apply-osm-matches.js run.
  • Renumbered spec 028 → 029 to avoid collision with the merged PWA spec.
  • Gourmand clean (one justified exception for the matcher's pipeline-stage helpers).

Closes #7

Test plan

  • ./run.sh build passes
  • Migration 067 applies idempotently; columns + constraints present
  • Both importers idempotent (re-run = no dupes, no nulled values)
  • API returns fields for amenities + curated POIs
  • Sidebar shows Hours/Accessibility/Fee; humanizer falls back to raw for complex rules
  • Admin edit inputs save; "Unknown" clears without CHECK violation
  • Gourmand clean
  • Post-merge: run import-osm-amenities.js + apply-osm-matches.js on prod (manual deploy steps)

🤖 Generated with Claude Code

fatherlinux and others added 2 commits May 28, 2026 19:56
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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +108 to +109
[name, feature.lat, feature.lon, meta.activity, description, feature.osm_id, moreInfoLink,
feature.opening_hours || null, feature.wheelchair || null, feature.fee || null]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]

Comment on lines +55 to +65
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
    }

Comment thread backend/server.js
Comment on lines +324 to +327
-- OSM-sourced visitor info (#7); see migration 067 for CHECK constraints
opening_hours TEXT,
wheelchair VARCHAR(12),
fee VARCHAR(12),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
-- 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 && (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
{destination.opening_hours && (
{destination.opening_hours && destination.opening_hours.trim() && (

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fatherlinux fatherlinux merged commit f0f05ee into master May 29, 2026
3 checks passed
@fatherlinux fatherlinux deleted the feature/7-osm-visitor-info branch May 29, 2026 00:05
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.

Add Cost, Limited Mobility and Hours Fields for Activities

1 participant