diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 599cb96..999bc6b 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -45,6 +45,9 @@ jobs: cache: gradle - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Fetch Offware's shared data (stations worldwide, landmarks) + # Never fails the build: an unreachable dataset leaves the committed files as they are. + run: pnpm --filter postcards places - name: Build the web app run: pnpm --filter postcards build - name: Sync the Android project diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 44024a8..9ab8195 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -9,6 +9,9 @@ on: push: branches: - main + # Weekly, after the shared data's Monday build, so stations and landmarks stay current. + schedule: + - cron: "0 6 * * 1" workflow_dispatch: permissions: @@ -35,6 +38,9 @@ jobs: cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Fetch Offware's shared data (stations worldwide, landmarks) + # Never fails the build: an unreachable dataset leaves the committed files as they are. + run: pnpm --filter postcards places - name: Build (GitHub Pages subpath) run: pnpm --filter postcards build env: diff --git a/.gitignore b/.gitignore index c9e32cd..e7125fc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ Thumbs.db .env .env.* !.env.example + +# Fetched at build time from the Offware places dataset (scripts/fetch-places.mjs) +apps/postcards/public/reference/railways-wikidata.json diff --git a/apps/postcards/package.json b/apps/postcards/package.json index 6683642..2a091d4 100644 --- a/apps/postcards/package.json +++ b/apps/postcards/package.json @@ -16,6 +16,7 @@ "format": "prettier --write .", "reference": "node scripts/build-reference.mjs", "railways": "node scripts/build-railways.mjs", + "places": "node scripts/fetch-places.mjs", "cap:sync": "cap sync", "cap:copy": "cap copy", "cap:add:android": "cap add android", diff --git a/apps/postcards/public/reference/PROVENANCE.md b/apps/postcards/public/reference/PROVENANCE.md index 9185ccd..5f417d7 100644 --- a/apps/postcards/public/reference/PROVENANCE.md +++ b/apps/postcards/public/reference/PROVENANCE.md @@ -13,7 +13,9 @@ mirrors it for people browsing the repository. | `subdivisions.json` | First-level regions: each city's GeoNames admin-1 code () and each code's GeoNames name (), both retrieved 2026-09-30; France's 13 regions by their French names; a code GeoNames does not name keeps one from dr5hn countries-states-cities — | CC BY 4.0 (GeoNames) / ODbL (dr5hn) | all-the-cities 3.1.0 + GeoNames 2026-09-30 + country-state-city | | `airports.json` | OpenFlights (IATA-coded, aggregated from OurAirports) — | ODbL 1.0 / OurAirports public domain | airport-data 1.0.1 | | `heritage.json` | UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025) — | CC BY-SA 3.0 IGO — © UNESCO World Heritage Centre | 2025 list, retrieved 2026-07-11 | -| `landmarks.json` | Famous landmarks seed (full set via Wikidata build script) — | CC0 (facts) | seed | +| `landmarks.json` | Landmarks (museums, monuments, castles, palaces, parks; UNESCO sites excluded), the 10,000 best known, from Wikidata via Offware's shared data — . Written at build time by `scripts/fetch-places.mjs`; the committed file is a small seed used when the dataset can't be reached. Each id is `wd-`. | CC0 1.0 | fetched at build time | +| `railways-wikidata.json` | Railway stations worldwide from Wikidata (open mainline stations, instance of Q55488) via Offware's shared data — . Written at build time by `scripts/fetch-places.mjs`, not committed. Each id is the station's Wikidata QID. | CC0 1.0 | fetched at build time | +| `railways.json` | Railway stations in Europe from the Trainline EU open stations database — | ODbL 1.0 | `scripts/build-railways.mjs` | | `languages.json` | Per-country spoken languages (via world-countries) — | ODbL 1.0 | 5.1.0 | | `article-names.json` | Common country names (Wikivoyage/Wikipedia article titles, via world-countries) — | ODbL 1.0 | 5.1.0 | | `experiences.json` | Postcards "Moments" — app-authored name/hint/emoji only; anchor coordinates aggregated from Wikidata () / GeoNames (); experience concepts attributed to UNESCO World Heritage (), UNESCO Intangible Cultural Heritage () & Wikivoyage travel topics (). Per-item `sources` cite the specific record. | Mixed — name/hint/emoji CC0; Wikidata CC0; GeoNames CC BY 4.0; UNESCO CC BY-SA 3.0 IGO; Wikivoyage CC BY-SA 3.0 | V1 | diff --git a/apps/postcards/scripts/fetch-places.mjs b/apps/postcards/scripts/fetch-places.mjs new file mode 100644 index 0000000..1d9fcbe --- /dev/null +++ b/apps/postcards/scripts/fetch-places.mjs @@ -0,0 +1,74 @@ +// Fetch Offware's shared data (https://github.com/offware-apps/data) into +// the bundled reference files, at build time: +// +// public/reference/railways-wikidata.json railway stations worldwide (Wikidata, CC0) +// public/reference/landmarks.json sights beyond UNESCO's list (Wikidata, CC0) +// +// Aggregator-only (Constitution I): the dataset reshapes Wikidata facts, each record +// keeping its Wikidata id as provenance; this script only downloads and checks it. +// A file that can't be fetched or doesn't validate is left as it is (the committed +// landmarks seed, or the stations of an earlier run). With no stations file at all the +// script fails: they're the default station source, and a build without them would +// leave every new user with no stations. +// +// node scripts/fetch-places.mjs +// PLACES_BASE=https://example.org/data/v1 node scripts/fetch-places.mjs + +import { existsSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const BASE = process.env.PLACES_BASE ?? "https://offware-apps.github.io/data/v1"; +const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "reference"); +const LANDMARK_MAX = 10_000; +const UA = "Postcards-reference-build/1.0 (https://github.com/offware-apps/Postcards)"; + +const finite = (n, max) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= max; +const validPoint = (p) => + p && typeof p.id === "string" && typeof p.name === "string" && p.name.trim() && + /^[A-Z]{2}$/.test(p.countryIso2) && finite(p.lat, 90) && finite(p.lon, 180); + +async function get(path) { + const res = await fetch(`${BASE}/${path}`, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`); + return res.json(); +} + +async function stations() { + const json = await get("stations/railways.json"); + if (json?._source?.license !== "CC0-1.0" || !Array.isArray(json.stations)) throw new Error("stations/railways.json: unexpected shape or licence"); + const list = json.stations.filter(validPoint); + if (list.length < 1000) throw new Error(`stations/railways.json: only ${list.length} valid stations`); + writeFileSync(join(OUT, "railways-wikidata.json"), JSON.stringify({ _source: json._source, stations: list })); + return `${list.length} stations`; +} + +async function landmarks() { + const [list, sources] = await Promise.all([get("places/landmarks.json"), get("sources.json")]); + const wikidata = sources?.sources?.find((s) => s.id === "wikidata"); + if (wikidata?.license !== "CC0-1.0" || !Array.isArray(list)) throw new Error("places/landmarks.json: unexpected shape or licence"); + // The best known LANDMARK_MAX, so the file stays small enough to load at start-up. + // Postcards' landmarks are one category; the dataset's finer kinds stay out of the app. + const out = list + .filter(validPoint) + .sort((a, b) => (b.sitelinks ?? 0) - (a.sitelinks ?? 0) || a.id.localeCompare(b.id)) + .slice(0, LANDMARK_MAX) + .map(({ id, name, countryIso2, lat, lon }) => ({ id, name, countryIso2, lat, lon, category: "landmark" })) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + if (out.length < 100) throw new Error(`places/landmarks.json: only ${out.length} valid landmarks`); + writeFileSync(join(OUT, "landmarks.json"), JSON.stringify(out)); + return `${out.length} landmarks`; +} + +for (const [name, run] of [["stations", stations], ["landmarks", landmarks]]) { + try { + process.stderr.write(`places: ${await run()} written\n`); + } catch (e) { + process.stderr.write(`places: ${name} kept as it was (${e?.message ?? e})\n`); + } +} + +if (!existsSync(join(OUT, "railways-wikidata.json"))) { + process.stderr.write("places: no railway stations file, the default station source would be empty\n"); + process.exit(1); +} diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index f0cfc68..91fe2b8 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -84,10 +84,13 @@ export const en = { "Used everywhere a country is counted: totals, the passport, per-continent progress.", "settings.stations.title": "Railway stations", "settings.stations.recommended": "recommended", + "settings.stations.source.wikidata": "World — Wikidata", "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "None", + "settings.stations.desc.wikidata": + "Open railway stations worldwide from Wikidata (CC0), via Offware's shared data. Each station links back to its Wikidata item, where anyone can correct it.", "settings.stations.desc.trainline": - "~44,000 European stations from Trainline's open database (ODbL). The only worldwide-open set that loads on this build; a global set (Wikidata/OSM) can be added later.", + "~44,000 European stations from Trainline's open database (ODbL). Choose it to keep stations you marked from this list before the worldwide set arrived.", "settings.stations.desc.none": "No railway stations are loaded — they won't appear in search, on the map, or in stats.", "settings.map.advanced": "More map options & offline packs", "settings.optimize.title": "Optimisation", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 094153b..f15cbd9 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -78,10 +78,13 @@ export const fr: Messages = { "Utilisé partout où un pays est compté : totaux, passeport, progression par continent.", "settings.stations.title": "Gares ferroviaires", "settings.stations.recommended": "recommandé", + "settings.stations.source.wikidata": "Monde — Wikidata", "settings.stations.source.trainline": "Europe — Trainline", "settings.stations.source.none": "Aucune", + "settings.stations.desc.wikidata": + "Les gares ferroviaires ouvertes du monde entier, issues de Wikidata (CC0) via les données partagées d'Offware. Chaque gare renvoie à sa fiche Wikidata, que chacun peut corriger.", "settings.stations.desc.trainline": - "~44 000 gares européennes issues de la base ouverte de Trainline (ODbL). Le seul jeu ouvert disponible sur cette version ; un jeu mondial (Wikidata/OSM) pourra être ajouté plus tard.", + "~44 000 gares européennes issues de la base ouverte de Trainline (ODbL). Choisissez-la pour garder les gares marquées depuis cette liste avant l'arrivée du jeu mondial.", "settings.stations.desc.none": "Aucune gare n'est chargée — elles n'apparaîtront ni dans la recherche, ni sur la carte, ni dans les stats.", "settings.map.advanced": "Plus d'options de carte et cartes hors ligne", "settings.optimize.title": "Optimisation", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 0956acc..4d2c75b 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -78,10 +78,13 @@ export const ko: Messages = { "국가를 세는 모든 곳에 사용됩니다: 합계, 여권, 대륙별 진행률.", "settings.stations.title": "기차역", "settings.stations.recommended": "권장", + "settings.stations.source.wikidata": "전 세계 — Wikidata", "settings.stations.source.trainline": "유럽 — Trainline", "settings.stations.source.none": "없음", + "settings.stations.desc.wikidata": + "Offware 공유 데이터를 통해 Wikidata(CC0)에서 가져온 전 세계 운행 중인 기차역. 각 역은 누구나 수정할 수 있는 Wikidata 항목으로 연결됩니다.", "settings.stations.desc.trainline": - "Trainline 공개 데이터베이스(ODbL)의 유럽 기차역 약 44,000곳. 이 빌드에서 불러올 수 있는 유일한 공개 데이터이며, 전 세계 데이터(Wikidata/OSM)는 나중에 추가할 수 있습니다.", + "Trainline 공개 데이터베이스(ODbL)의 유럽 기차역 약 44,000곳. 전 세계 데이터가 추가되기 전에 이 목록에서 표시한 역을 유지하려면 선택하세요.", "settings.stations.desc.none": "기차역을 불러오지 않습니다 — 검색, 지도, 통계에 나타나지 않습니다.", "settings.map.advanced": "추가 지도 옵션 및 오프라인 팩", "settings.optimize.title": "최적화", diff --git a/apps/postcards/src/lib/reference/data/provenance.json b/apps/postcards/src/lib/reference/data/provenance.json index bdd007a..d2548bb 100644 --- a/apps/postcards/src/lib/reference/data/provenance.json +++ b/apps/postcards/src/lib/reference/data/provenance.json @@ -30,9 +30,15 @@ "url": "https://openflights.org/data.html" }, { - "dataset": "Railway stations — Trainline EU open stations database (choosable in Settings)", + "dataset": "Railway stations worldwide — Wikidata via Offware shared data (default; choosable in Settings)", + "license": "CC0-1.0", + "version": "fetched at build time", + "url": "https://github.com/offware-apps/data" + }, + { + "dataset": "Railway stations — Trainline EU open stations database (Europe; choosable in Settings)", "license": "ODbL-1.0", - "version": "trainline · worldwide: --source=wikidata", + "version": "trainline", "url": "https://github.com/trainline-eu/stations" }, { @@ -54,10 +60,10 @@ "url": "https://en.wikivoyage.org" }, { - "dataset": "Famous landmarks seed (full set via Wikidata build script)", - "license": "CC0 (facts)", - "version": "seed", - "url": "https://www.wikidata.org" + "dataset": "Landmarks — the 10,000 best-known sights (Wikidata, via Offware's shared data)", + "license": "CC0-1.0", + "version": "fetched at build time", + "url": "https://github.com/offware-apps/data" }, { "dataset": "UNESCO World Heritage List — all 1,248 sites incl. 47th session (2025)", diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 928a041..9b8a413 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -482,7 +482,7 @@ async function loadExtras(impl: ReferenceDataImpl): Promise { .then((r) => (r.ok ? (r.json() as Promise) : empty)) .catch(() => empty); // Railway stations: whichever dataset the user chose in Settings (default - // Trainline; "None" loads nothing). A { _source, stations:[…] } wrapper; + // Wikidata, worldwide; "None" loads nothing). A { _source, stations:[…] } wrapper; // absent file → []. Switching source later re-fetches via setStationData. // The biggest file here (5 MB, a long parse on a phone), so it waits for a // screen to ask (requestStations): on the map, once the map has loaded, so it diff --git a/apps/postcards/src/lib/reference/stationSources.ts b/apps/postcards/src/lib/reference/stationSources.ts index f1a210d..7ed9683 100644 --- a/apps/postcards/src/lib/reference/stationSources.ts +++ b/apps/postcards/src/lib/reference/stationSources.ts @@ -3,15 +3,14 @@ // aggregator, never author) bundled as its own file under public/reference/; the // user picks which one they load — or "None" to carry none at all. // -// This catalogue is DATA-DRIVEN: the Settings picker lists exactly what's here, so -// adding a worldwide build output (e.g. railways-wikidata.json from -// `node scripts/build-railways.mjs --source=wikidata`) is a one-line addition that -// automatically shows up as a selectable source — no UI change needed. +// This catalogue is DATA-DRIVEN: the Settings picker lists exactly what's here. +// The worldwide set comes from Offware's shared data (Wikidata, CC0), +// fetched at build time by `scripts/fetch-places.mjs`. const BASE = import.meta.env.BASE_URL; /** Stable ids, persisted in localStorage — never renumber. "none" loads nothing. */ -export type StationSourceId = "trainline" | "none"; +export type StationSourceId = "wikidata" | "trainline" | "none"; export interface StationSourceDef { id: StationSourceId; @@ -24,23 +23,34 @@ export interface StationSourceDef { recommended: boolean; } -// Trainline (ODbL, Europe) is the recommended default — the only full open station -// set reachable from the build sandbox. A future worldwide file (Wikidata CC0 / -// OSM ODbL), produced by the build script on an open network, slots in here. +// Wikidata (CC0, worldwide) is the recommended default. Trainline (ODbL, Europe) +// stays selectable: its station ids differ, so people who marked stations from it +// keep it until they choose otherwise (see loadStationSource). export const STATION_SOURCES: readonly StationSourceDef[] = [ + { + id: "wikidata", + url: `${BASE}reference/railways-wikidata.json`, + license: "CC0-1.0", + coverage: "worldwide", + recommended: true, + }, { id: "trainline", url: `${BASE}reference/railways.json`, license: "ODbL-1.0", coverage: "europe", - recommended: true, + recommended: false, }, { id: "none", url: null, license: "", coverage: "none", recommended: false }, ]; -export const DEFAULT_STATION_SOURCE: StationSourceId = "trainline"; +export const DEFAULT_STATION_SOURCE: StationSourceId = "wikidata"; const STATION_SOURCE_KEY = "postcards-station-source"; +// Set once the app has been opened before (App.tsx). Before the worldwide set, +// Trainline was everyone's default, so an earlier user who never chose keeps it: +// their marked stations carry Trainline ids, which the worldwide set doesn't have. +const RETURNING_USER_KEY = "postcards-intro-seen"; export function isStationSource(v: unknown): v is StationSourceId { return STATION_SOURCES.some((s) => s.id === v); @@ -55,7 +65,12 @@ export function stationSourceById(id: StationSourceId): StationSourceDef { export function loadStationSource(): StationSourceId { try { const v = localStorage.getItem(STATION_SOURCE_KEY); - return isStationSource(v) ? v : DEFAULT_STATION_SOURCE; + if (isStationSource(v)) return v; + // Either way the choice is saved now: a new user's intro flag, set on this + // first run, must not turn them into a "returning" Trainline user next time. + const choice: StationSourceId = localStorage.getItem(RETURNING_USER_KEY) != null ? "trainline" : DEFAULT_STATION_SOURCE; + localStorage.setItem(STATION_SOURCE_KEY, choice); + return choice; } catch { return DEFAULT_STATION_SOURCE; } diff --git a/apps/postcards/tests/unit/referenceStartup.spec.ts b/apps/postcards/tests/unit/referenceStartup.spec.ts index 83be1ee..cd3e8b3 100644 --- a/apps/postcards/tests/unit/referenceStartup.spec.ts +++ b/apps/postcards/tests/unit/referenceStartup.spec.ts @@ -39,7 +39,7 @@ describe("reference data at startup", () => { // The stations, the biggest file, are not asked for until a screen asks // (the map once it has loaded), so they never compete with it. - expect(late["railways.json"]).toBeUndefined(); + expect(late["railways-wikidata.json"]).toBeUndefined(); const gen = gazetteerGeneration(); const next = () => @@ -58,11 +58,11 @@ describe("reference data at startup", () => { // Not on idle either: only on request. await new Promise((r) => setTimeout(r, 1600)); - expect(late["railways.json"]).toBeUndefined(); + expect(late["railways-wikidata.json"]).toBeUndefined(); requestStations(); - await vi.waitFor(() => expect(late["railways.json"]).toBeDefined(), { timeout: 3000 }); + await vi.waitFor(() => expect(late["railways-wikidata.json"]).toBeDefined(), { timeout: 3000 }); landed = next(); - late["railways.json"]!({ + late["railways-wikidata.json"]!({ stations: [{ id: "Q1", name: "Lisboa Oriente", countryIso2: "PT", lat: 38.77, lon: -9.1 }], }); await landed; @@ -94,9 +94,9 @@ describe("reference data at startup", () => { late["languages.json"]!({}); late["article-names.json"]!({}); requestStations(); - await vi.waitFor(() => expect(late["railways.json"]).toBeDefined(), { timeout: 3000 }); + await vi.waitFor(() => expect(late["railways-wikidata.json"]).toBeDefined(), { timeout: 3000 }); const landed = new Promise((r) => window.addEventListener(GAZETTEER_UPGRADED_EVENT, r, { once: true })); - late["railways.json"]!({ + late["railways-wikidata.json"]!({ stations: [{ id: "Q1", name: "Lisboa Oriente", countryIso2: "PT", lat: 38.77, lon: -9.1 }], }); await landed; diff --git a/apps/postcards/tests/unit/stations.spec.ts b/apps/postcards/tests/unit/stations.spec.ts index b79e2c7..0fcf893 100644 --- a/apps/postcards/tests/unit/stations.spec.ts +++ b/apps/postcards/tests/unit/stations.spec.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, it, expect } from "vitest"; import { initReferenceDataSync, setStationData } from "../../src/lib/reference/referenceData"; import type { Station } from "../../src/lib/reference/types"; import { STATION_SOURCES, DEFAULT_STATION_SOURCE, isStationSource, + loadStationSource, stationSourceById, } from "../../src/lib/reference/stationSources"; import { PlaceRefSchema } from "../../src/lib/schema/models"; @@ -71,6 +72,11 @@ describe("station data sources (Settings choice)", () => { expect(STATION_SOURCES.filter((s) => s.recommended)).toHaveLength(1); }); + it("defaults to the worldwide Wikidata set", () => { + expect(DEFAULT_STATION_SOURCE).toBe("wikidata"); + expect(stationSourceById("wikidata")).toMatchObject({ coverage: "worldwide", license: "CC0-1.0" }); + }); + it("rejects unknown source ids", () => { expect(isStationSource("osm")).toBe(false); expect(isStationSource(null)).toBe(false); @@ -88,3 +94,24 @@ describe("station data sources (Settings choice)", () => { expect(ref.allStations()).toEqual([]); }); }); + +describe("the station source a person starts with", () => { + beforeEach(() => localStorage.clear()); + + it("gives a new user the worldwide set, and keeps it after the first run", () => { + expect(loadStationSource()).toBe("wikidata"); + localStorage.setItem("postcards-intro-seen", "1"); // set at the end of the first run + expect(loadStationSource()).toBe("wikidata"); + }); + + it("keeps an earlier user on Trainline, whose station ids their visits use", () => { + localStorage.setItem("postcards-intro-seen", "1"); + expect(loadStationSource()).toBe("trainline"); + }); + + it("never overrides a choice made in Settings", () => { + localStorage.setItem("postcards-intro-seen", "1"); + localStorage.setItem("postcards-station-source", "wikidata"); + expect(loadStationSource()).toBe("wikidata"); + }); +}); diff --git a/apps/postcards/vite.config.ts b/apps/postcards/vite.config.ts index 40403d6..aaff671 100644 --- a/apps/postcards/vite.config.ts +++ b/apps/postcards/vite.config.ts @@ -76,7 +76,9 @@ export default defineConfig({ // top-10k core and downloads the full set ON DEMAND (a one-tap Settings // action, like a tile pack). Runtime-cached below so it reopens offline // once fetched. Never bundled — that would defeat the small-install goal. - globIgnores: ["**/reference/cities-all.json"], + // The worldwide stations (fetched at build time from the places dataset) can + // outgrow the precache limit; they're runtime-cached below instead. + globIgnores: ["**/reference/cities-all.json", "**/reference/railways-wikidata.json"], maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, // Control the page on the very first visit, so a tile pack or the full // city list downloaded in that session reaches the runtime caches below @@ -100,6 +102,17 @@ export default defineConfig({ cacheableResponse: { statuses: [200] }, }, }, + { + // Worldwide railway stations: cached when the map first asks for them, + // so they reopen offline; refreshed in the background after a release. + urlPattern: ({ url }) => url.pathname.endsWith("/reference/railways-wikidata.json"), + handler: "StaleWhileRevalidate", + options: { + cacheName: "stations-v1", + expiration: { maxEntries: 2 }, + cacheableResponse: { statuses: [200] }, + }, + }, { urlPattern: ({ url }) => url.hostname.endsWith("tile.openstreetmap.org"), handler: "CacheFirst", diff --git a/specs/022-shared-places/plan.md b/specs/022-shared-places/plan.md new file mode 100644 index 0000000..5658081 --- /dev/null +++ b/specs/022-shared-places/plan.md @@ -0,0 +1,24 @@ +# Implementation Plan: Worldwide stations and landmarks from Offware's shared data + +**Spec**: `specs/022-shared-places/spec.md` + +## Constitution Check + +- **I. Aggregator, never an author**: pass. Data comes from Wikidata (CC0) through the places + dataset; ids are Wikidata items; provenance updated. +- **Local-first / offline**: pass. Files are bundled at build time; stations runtime-cached. +- **Privacy**: pass. The fetch happens in CI at build time, never from a user's device. +- **Data is inert**: pass. The build script validates shape, licence and coordinates. +- **Zero lock-in**: pass. Static JSON on GitHub Pages; `PLACES_BASE` points it elsewhere. + +## Design + +- `scripts/fetch-places.mjs` (`pnpm places`): downloads, validates, keeps the 10,000 best-known + landmarks, writes the two files; failure leaves files as they are. +- `src/lib/reference/stationSources.ts`: adds `wikidata` (default, recommended); the first + `loadStationSource()` without a saved choice pins Trainline for a returning user (intro already + seen) and Wikidata for a new one. +- `vite.config.ts`: `railways-wikidata.json` out of the precache, into a runtime cache. +- Workflows: `pages.yml` and `android-apk.yml` run `pnpm places` before building; `pages.yml` also + runs weekly after the dataset's Monday build. +- `railways-wikidata.json` is git-ignored: it is produced at build time. diff --git a/specs/022-shared-places/spec.md b/specs/022-shared-places/spec.md new file mode 100644 index 0000000..2e14d87 --- /dev/null +++ b/specs/022-shared-places/spec.md @@ -0,0 +1,63 @@ +# Feature Specification: Worldwide stations and landmarks from Offware's shared data + +**Feature Branch**: `022-shared-places` + +**Created**: 2026-10-12 + +**Status**: Implemented + +**Input**: Maintainer: "update Postcards to use that centralized dataset. It uses only European +trains; make sure it uses worldwide train stations." The dataset is +[offware-apps/data](https://github.com/offware-apps/data), shared by the Offware apps: towns, +sights and railway stations from Wikidata (CC0), every record keeping its Wikidata id. + +## Overview + +Postcards' railway stations come from Trainline's open database, which covers Europe only, and its +landmarks are a hand-picked seed of ~70. Offware's shared data publishes, every week, +open railway stations worldwide and the best-known sights of every town, both from Wikidata. This +feature makes Postcards read both from it, keeping Constitution I: the app still authors nothing, +and every record's provenance (its Wikidata item) travels with it. + +## User Scenarios & Testing + +### User Story 1 - Mark a station anywhere in the world (Priority: P1) + +A traveller who took the train in Japan searches "Tokyo Station", finds it, and marks it visited, +offline once the stations have loaded. + +**Acceptance Scenarios**: + +1. **Given** a new install, **When** the user searches a station outside Europe, **Then** it is + found, from the worldwide set, which is the recommended default. +2. **Given** the user marked stations from the Europe (Trainline) set before this release and never + chose a source, **When** they update, **Then** they stay on Trainline, so their marked stations + keep resolving; Settings offers the worldwide set. +3. **Given** any user, **When** they pick a source in Settings, **Then** that choice is kept. + +### User Story 2 - See well-known sights beyond UNESCO's list (Priority: P2) + +Museums, monuments, castles, palaces and parks show among sites & landmarks: the 10,000 best known +worldwide (by how many Wikipedia editions cover them), replacing the seed. UNESCO sites keep coming +from UNESCO's own list, so none is listed twice. + +## Requirements + +- **FR-001**: The build fetches `stations/railways.json`, `places/landmarks.json` and `sources.json` from the + shared data (`scripts/fetch-places.mjs`) and writes `public/reference/railways-wikidata.json` + and `public/reference/landmarks.json`. +- **FR-002**: A file is written only when it validates: the expected shape, a CC0 licence, and + enough valid records. Otherwise the existing file stays. The build fails only when no + stations file exists at all, since the default source would then be empty. +- **FR-003**: A "World — Wikidata" station source is the recommended default; Trainline and None + stay selectable. +- **FR-004**: A person who used the app before and never chose keeps Trainline (FR scenario 1.2). +- **FR-005**: The worldwide station file is runtime-cached (stale-while-revalidate), as it can + outgrow the precache size limit. +- **FR-006**: Provenance (Settings and `public/reference/PROVENANCE.md`) names the shared data, + Wikidata and CC0 for both files. +- **FR-007**: Strings in en, fr and ko. + +## Out of scope + +Converting Trainline-id visits to Wikidata ids; per-category landmark glyphs. diff --git a/specs/022-shared-places/tasks.md b/specs/022-shared-places/tasks.md new file mode 100644 index 0000000..168c0be --- /dev/null +++ b/specs/022-shared-places/tasks.md @@ -0,0 +1,11 @@ +# Tasks: Worldwide stations and landmarks from Offware's shared data + +All paths under `apps/postcards/` unless noted. + +- [x] T001 `scripts/fetch-places.mjs` + `pnpm places`, with validation and a never-failing fallback +- [x] T002 `wikidata` station source as the recommended default; returning users pinned to Trainline +- [x] T003 Unit tests: default source, new vs returning user, saved choice kept (`tests/unit/stations.spec.ts`) +- [x] T004 Runtime cache for `railways-wikidata.json` (`vite.config.ts`) +- [x] T005 en/fr/ko strings for the new source and the updated Trainline description +- [x] T006 Provenance: `src/lib/reference/data/provenance.json`, `public/reference/PROVENANCE.md` +- [x] T007 Workflows: fetch before building (`.github/workflows/pages.yml`, `android-apk.yml`); weekly Pages build