Skip to content

Commit 4a44316

Browse files
davd-gzlclaude
andcommitted
Railway stations MVP (US1): schema kind + reference seam + search/mark
Add 'station' as a place kind backed by a Wikidata (CC0) railway dataset, resolved through the reference-data seam like airports/heritage. - schema: kind enum gains "station"; SCHEMA_VERSION bump + version history - reference: Station type, allStations/stationById/searchStations/stationsOf, STATIONS_URL fetch mapping .stations (absent -> empty), sync init param - coord resolvers gain a station branch (distance, myPlaces, placeCoords, StoryMap, exportCsv); place flag 🚉 - search: name-only station results + country-intent station block - Places: placeMeta + row glyph for stations; i18n places.meta.station - dev fixture public/reference/railways.json (13 real stations, placeholder coords) until `pnpm railways` fetches the full CC0 set - tests: tests/unit/stations.spec.ts (seam), tests/e2e/stations.spec.ts (offline search + mark, zero egress) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W6tgHRgLEugCsbKC9ccST
1 parent 1163cba commit 4a44316

19 files changed

Lines changed: 277 additions & 24 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"_source": {
3+
"dataset": "Wikidata railway stations (instance of Q55488)",
4+
"url": "https://query.wikidata.org/",
5+
"license": "CC0-1.0",
6+
"retrieved": "2026-07-24",
7+
"note": "DEVELOPMENT FIXTURE — a small placeholder set of well-known mainline stations (approximate public coordinates, non-authoritative ids). Replace with the authoritative Wikidata CC0 dataset by running `pnpm railways` where query.wikidata.org is reachable."
8+
},
9+
"stations": [
10+
{ "id": "fx-paris-gare-de-lyon", "name": "Paris Gare de Lyon", "countryIso2": "FR", "subdivisionId": null, "lat": 48.8443, "lon": 2.3735 },
11+
{ "id": "fx-paris-gare-du-nord", "name": "Paris Gare du Nord", "countryIso2": "FR", "subdivisionId": null, "lat": 48.8809, "lon": 2.3553 },
12+
{ "id": "fx-lyon-part-dieu", "name": "Lyon Part-Dieu", "countryIso2": "FR", "subdivisionId": null, "lat": 45.7605, "lon": 4.8592 },
13+
{ "id": "fx-marseille-saint-charles", "name": "Marseille Saint-Charles", "countryIso2": "FR", "subdivisionId": null, "lat": 43.3026, "lon": 5.3804 },
14+
{ "id": "fx-london-st-pancras", "name": "London St Pancras International", "countryIso2": "GB", "subdivisionId": null, "lat": 51.5313, "lon": -0.1263 },
15+
{ "id": "fx-london-kings-cross", "name": "London King's Cross", "countryIso2": "GB", "subdivisionId": null, "lat": 51.5312, "lon": -0.1236 },
16+
{ "id": "fx-berlin-hauptbahnhof", "name": "Berlin Hauptbahnhof", "countryIso2": "DE", "subdivisionId": null, "lat": 52.5250, "lon": 13.3694 },
17+
{ "id": "fx-munchen-hauptbahnhof", "name": "München Hauptbahnhof", "countryIso2": "DE", "subdivisionId": null, "lat": 48.1402, "lon": 11.5586 },
18+
{ "id": "fx-roma-termini", "name": "Roma Termini", "countryIso2": "IT", "subdivisionId": null, "lat": 41.9010, "lon": 12.5020 },
19+
{ "id": "fx-madrid-atocha", "name": "Madrid Puerta de Atocha", "countryIso2": "ES", "subdivisionId": null, "lat": 40.4065, "lon": -3.6895 },
20+
{ "id": "fx-tokyo-station", "name": "Tokyo Station", "countryIso2": "JP", "subdivisionId": null, "lat": 35.6812, "lon": 139.7671 },
21+
{ "id": "fx-kyoto-station", "name": "Kyoto Station", "countryIso2": "JP", "subdivisionId": null, "lat": 34.9858, "lon": 135.7588 },
22+
{ "id": "fx-newyork-penn", "name": "New York Penn Station", "countryIso2": "US", "subdivisionId": null, "lat": 40.7506, "lon": -73.9935 }
23+
]
24+
}

apps/postcards/src/features/backup/exportCsv.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ function coordOf(ref: ReferenceData, v: Visit): { lat: number; lon: number } | n
1919
const a = ref.airportById(p.id);
2020
return a ? { lat: a.lat, lon: a.lon } : null;
2121
}
22+
if (p.kind === "station") {
23+
const s = ref.stationById(p.id);
24+
return s ? { lat: s.lat, lon: s.lon } : null;
25+
}
2226
if (p.kind === "custom" && p.lat != null && p.lon != null) return { lat: p.lat, lon: p.lon };
2327
return null;
2428
}

apps/postcards/src/features/journal/StoryMap.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ function coordOf(ref: ReferenceData, p: PlaceRef): { lon: number; lat: number }
3636
const a = ref.airportById(p.id);
3737
return a ? { lon: a.lon, lat: a.lat } : null;
3838
}
39+
if (p.kind === "station") {
40+
const s = ref.stationById(p.id);
41+
return s ? { lon: s.lon, lat: s.lat } : null;
42+
}
3943
if (p.kind === "custom") {
4044
return p.lat != null && p.lon != null ? { lon: p.lon, lat: p.lat } : null;
4145
}

apps/postcards/src/features/travel/distance.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function coordsOf(place: PlaceRef, ref: ReferenceData): { lon: number; la
1212
const a = ref.airportById(place.id);
1313
return a ? { lon: a.lon, lat: a.lat } : null;
1414
}
15+
if (place.kind === "station") {
16+
const s = ref.stationById(place.id);
17+
return s ? { lon: s.lon, lat: s.lat } : null;
18+
}
1519
if (place.kind === "heritage") {
1620
const h = ref.heritageById(place.id);
1721
// Some sites have no coordinate in the source (stored as 0,0) — treat as unknown.

apps/postcards/src/features/travel/myPlaces.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { countryFlag } from "../../lib/format/format";
66
/** The emoji that stands in for a place in the trip UI — a plane for airports,
77
* else the country flag. One definition shared by every trip picker/row. */
88
export const placeFlag = (p: PlaceRef): string =>
9-
p.kind === "airport" ? "✈️" : countryFlag(p.countryId);
9+
p.kind === "airport" ? "✈️" : p.kind === "station" ? "🚉" : countryFlag(p.countryId);
1010

1111
// The pool the trip composer picks stops from (spec 019, fast-reconstruction): ONLY
1212
// places you've already been — your visited records plus every place already used in
@@ -36,6 +36,10 @@ function coordOf(ref: ReferenceData, p: PlaceRef): { lon: number; lat: number }
3636
const a = ref.airportById(p.id);
3737
return a ? { lon: a.lon, lat: a.lat } : null;
3838
}
39+
if (p.kind === "station") {
40+
const s = ref.stationById(p.id);
41+
return s ? { lon: s.lon, lat: s.lat } : null;
42+
}
3943
if (p.kind === "heritage") {
4044
const h = ref.heritageById(p.id);
4145
return h && (h.lat !== 0 || h.lon !== 0) ? { lon: h.lon, lat: h.lat } : null;

apps/postcards/src/features/visits/PlacesScreen.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ function placeMeta(
134134
const a = ref.airportById(v.place.id);
135135
return { coord: a ? { lon: a.lon, lat: a.lat } : null, sub: `${t("places.meta.airport")} · ${country}` };
136136
}
137+
if (v.place.kind === "station") {
138+
const s = ref.stationById(v.place.id);
139+
return { coord: s ? { lon: s.lon, lat: s.lat } : null, sub: `${t("places.meta.station")} · ${country}` };
140+
}
137141
if (v.place.kind === "heritage") {
138142
const h = ref.heritageById(v.place.id);
139143
const coord = h && (h.lat !== 0 || h.lon !== 0) ? { lon: h.lon, lat: h.lat } : null;
@@ -299,7 +303,9 @@ const VisitRow = memo(function VisitRow({ v, wishlist }: { v: Visit; wishlist?:
299303
? heritageGlyph(ref.heritageById(v.place.id)?.category)
300304
: v.place.kind === "airport"
301305
? "✈️"
302-
: countryFlag(v.place.countryId);
306+
: v.place.kind === "station"
307+
? "🚉"
308+
: countryFlag(v.place.countryId);
303309

304310
return (
305311
<li className={"city-row compact" + (menuOpen ? " menu-open" : "")}>
@@ -391,7 +397,9 @@ const BrowseRowItem = memo(function BrowseRowItem({ r }: { r: BrowseRow }) {
391397
? countryFlag(r.countryIso2)
392398
: r.kind === "airport"
393399
? "✈️"
394-
: heritageGlyph(r.category);
400+
: r.kind === "station"
401+
? "🚉"
402+
: heritageGlyph(r.category);
395403
// Only a real dataset category becomes a tag — never an invented one (FR-008).
396404
const cat =
397405
r.kind === "heritage" &&

apps/postcards/src/features/visits/search.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ export function searchPlaces(ref: ReferenceData, query: string, limit = 8): Sear
4646
};
4747
});
4848

49+
// Railway stations — name-only (a QID is not a code), like heritage.
50+
const stationResults: SearchResult[] = ref.searchStations(q, limit).map((s) => {
51+
const country = ref.countryByIso2(s.countryIso2);
52+
return {
53+
place: { kind: "station", id: s.id, name: s.name, countryId: s.countryIso2 },
54+
detail: country ? `Station · ${country.name}` : "Station",
55+
};
56+
});
57+
4958
// A query typed as an explicit UPPERCASE 3-letter IATA code (e.g. "LAX", "CDG")
5059
// means the airport — surface it first, ahead of a like-named city ("Laxou").
5160
// Lowercase/mixed prefix typing ("por", "san") is treated as a place name, so
@@ -60,7 +69,7 @@ export function searchPlaces(ref: ReferenceData, query: string, limit = 8): Sear
6069
limit * 2,
6170
);
6271
}
63-
const grouped = [...countryResults, ...cityResults, ...airportResults, ...heritageResults];
72+
const grouped = [...countryResults, ...cityResults, ...airportResults, ...stationResults, ...heritageResults];
6473
// Rank by HOW a name matches, not by what kind it is: "Ista" must put
6574
// Istanbul (prefix) above Afghanistan (mid-word hit). The sort is stable, so
6675
// within a rank the kind order above still breaks ties.
@@ -97,12 +106,19 @@ export function searchPlaces(ref: ReferenceData, query: string, limit = 8): Sear
97106
place: { kind: "airport", id: a.id, name: `${a.name} (${a.id})`, countryId: iso2 },
98107
detail: `Airport · ${[a.city, cName].filter(Boolean).join(", ")}`,
99108
}));
109+
const sta: SearchResult[] = ref
110+
.stationsOf(iso2)
111+
.slice(0, limit)
112+
.map((s) => ({
113+
place: { kind: "station" as const, id: s.id, name: s.name, countryId: iso2 },
114+
detail: `Station · ${cName}`,
115+
}));
100116
const country: SearchResult = {
101117
place: { kind: "country", id: iso2, name: cName, countryId: iso2 },
102118
detail: "Country",
103119
};
104120
const seen = new Set<string>();
105-
return [country, ...her, ...air, ...ranked]
121+
return [country, ...her, ...air, ...sta, ...ranked]
106122
.filter((r) => {
107123
const k = `${r.place.kind}:${r.place.id}`;
108124
if (seen.has(k)) return false;

apps/postcards/src/lib/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export const en = {
333333
"places.rowMenu.notePlaceholder": "A short note",
334334
"places.row.removedToast": "Removed {name}",
335335
"places.meta.airport": "Airport",
336+
"places.meta.station": "Station",
336337
"places.meta.monument": "Monument",
337338
"places.meta.yourPlace": "Your place",
338339
"places.meta.country": "Country",

apps/postcards/src/lib/i18n/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ export const fr: Messages = {
327327
"places.rowMenu.notePlaceholder": "Une courte note",
328328
"places.row.removedToast": "{name} retiré",
329329
"places.meta.airport": "Aéroport",
330+
"places.meta.station": "Gare",
330331
"places.meta.monument": "Monument",
331332
"places.meta.yourPlace": "Votre lieu",
332333
"places.meta.country": "Pays",

apps/postcards/src/lib/i18n/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ export const ko: Messages = {
327327
"places.rowMenu.notePlaceholder": "짧은 메모",
328328
"places.row.removedToast": "{name} 삭제됨",
329329
"places.meta.airport": "공항",
330+
"places.meta.station": "역",
330331
"places.meta.monument": "기념물",
331332
"places.meta.yourPlace": "내 장소",
332333
"places.meta.country": "국가",

0 commit comments

Comments
 (0)