From 035c6677b9a0cdaca3cb027f0afa7f9c726f83f6 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:53:25 +0900 Subject: [PATCH 001/102] Filter panel: keep focus on the pressed filter Every filter tap moved focus back to the dialog itself. The hosts pass an inline onClose, so each tap re-rendered them with a new callback, and the focus effect listed it as a dependency: it re-ran, restored focus to the opener and focused the panel again. The effect now reads onClose through a ref and runs on open alone. --- apps/postcards/src/ui/FilterPanel.tsx | 8 +++- .../postcards/tests/unit/filterPanel.spec.tsx | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 apps/postcards/tests/unit/filterPanel.spec.tsx diff --git a/apps/postcards/src/ui/FilterPanel.tsx b/apps/postcards/src/ui/FilterPanel.tsx index 2959313..5f33385 100644 --- a/apps/postcards/src/ui/FilterPanel.tsx +++ b/apps/postcards/src/ui/FilterPanel.tsx @@ -40,6 +40,10 @@ export function FilterPanel({ const t = useT(); const f = useFilters(); const panelRef = useRef(null); + // Hosts pass an inline onClose, so a filter tap hands a new one; reading it through + // a ref keeps the focus effect from re-running (and re-focusing the panel) on it. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; // Focus the panel on open; restore focus to the opener on close; Escape closes; // Tab is trapped inside the panel (contracts/panel-a11y.md). @@ -51,7 +55,7 @@ export function FilterPanel({ function onKey(e: KeyboardEvent) { if (e.key === "Escape") { e.stopPropagation(); - onClose(); + onCloseRef.current(); return; } if (e.key !== "Tab" || !node) return; @@ -74,7 +78,7 @@ export function FilterPanel({ document.removeEventListener("keydown", onKey, true); opener?.focus?.(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; diff --git a/apps/postcards/tests/unit/filterPanel.spec.tsx b/apps/postcards/tests/unit/filterPanel.spec.tsx new file mode 100644 index 0000000..7f2cc46 --- /dev/null +++ b/apps/postcards/tests/unit/filterPanel.spec.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { useState } from "react"; +import { FilterPanel } from "../../src/ui/FilterPanel"; +import { useFilters, DEFAULT_FILTERS } from "../../src/lib/store/useFilters"; + +afterEach(() => { + cleanup(); + useFilters.setState({ ...DEFAULT_FILTERS }); +}); + +// The screens that host the panel subscribe to the filter store and pass an inline +// onClose, so every filter tap re-renders the host with a new callback. +function Host() { + useFilters(); + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} folders={[]} years={{ list: [], undated: false }} /> + + ); +} + +describe("FilterPanel focus", () => { + it("keeps focus on the pressed filter while the panel stays open", () => { + render(); + const opener = screen.getByText("opener"); + opener.focus(); + fireEvent.click(opener); + const btn = screen.getByRole("button", { name: /^visited$/i }); + btn.focus(); + fireEvent.click(btn); + expect(document.activeElement).toBe(btn); + }); + + it("returns focus to the opener on Escape", () => { + render(); + const opener = screen.getByText("opener"); + opener.focus(); + fireEvent.click(opener); + fireEvent.click(screen.getByRole("button", { name: /^visited$/i })); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(opener); + }); +}); From 465523c3c3b2697ab8087f5f12b0dd676e4301f4 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:53:54 +0900 Subject: [PATCH 002/102] Toast: auto-dismiss again after a hovered toast is closed Hovering or focusing a toast pauses its timer, and closing it from there unmounted it with no mouseleave or blur, so the pause stayed set and every later toast stayed on screen until closed by hand. The pause now clears when the toast goes. --- apps/postcards/src/ui/Toast.tsx | 2 ++ apps/postcards/tests/unit/toast.spec.tsx | 32 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 apps/postcards/tests/unit/toast.spec.tsx diff --git a/apps/postcards/src/ui/Toast.tsx b/apps/postcards/src/ui/Toast.tsx index c331dac..c001601 100644 --- a/apps/postcards/src/ui/Toast.tsx +++ b/apps/postcards/src/ui/Toast.tsx @@ -10,6 +10,8 @@ export function Toast() { const toast = useToast((s) => s.toast); const dismiss = useToast((s) => s.dismiss); const [paused, setPaused] = useState(false); + // A dismissed toast unmounts with no mouseleave or blur, so its pause goes with it. + if (!toast && paused) setPaused(false); useEffect(() => { if (!toast || paused) return; diff --git a/apps/postcards/tests/unit/toast.spec.tsx b/apps/postcards/tests/unit/toast.spec.tsx new file mode 100644 index 0000000..57fe587 --- /dev/null +++ b/apps/postcards/tests/unit/toast.spec.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"; +import { Toast } from "../../src/ui/Toast"; +import { useToast } from "../../src/lib/store/useToast"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + useToast.setState({ toast: null }); +}); + +describe("Toast auto-dismiss", () => { + it("holds while hovered", () => { + vi.useFakeTimers(); + render(); + act(() => useToast.getState().show("first")); + fireEvent.mouseEnter(screen.getByRole("status")); + act(() => vi.advanceTimersByTime(10_000)); + expect(useToast.getState().toast?.message).toBe("first"); + }); + + it("resumes for the next toast after a hovered one is closed", () => { + vi.useFakeTimers(); + render(); + act(() => useToast.getState().show("first")); + fireEvent.mouseEnter(screen.getByRole("status")); + fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); + act(() => useToast.getState().show("second")); + act(() => vi.advanceTimersByTime(10_000)); + expect(useToast.getState().toast).toBeNull(); + }); +}); From 4d0965d84147c1b9532bdf50869408c8c35295f3 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:55:54 +0900 Subject: [PATCH 003/102] Stat strip: stop remounting the counters on every store update The Counter component was declared inside StatStrip, so each render made a new component type and React replaced every counter button, dropping keyboard focus whenever a visit changed. Counter now lives at module scope. --- .../src/features/stats/StatStrip.tsx | 109 +++++++++--------- apps/postcards/tests/unit/statStrip.spec.tsx | 32 +++++ 2 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 apps/postcards/tests/unit/statStrip.spec.tsx diff --git a/apps/postcards/src/features/stats/StatStrip.tsx b/apps/postcards/src/features/stats/StatStrip.tsx index 6c48944..4c9acd4 100644 --- a/apps/postcards/src/features/stats/StatStrip.tsx +++ b/apps/postcards/src/features/stats/StatStrip.tsx @@ -8,6 +8,62 @@ import { computeCoverage } from "./computeStats"; import { formatInt, formatPercent, formatPercentFloor } from "../../lib/format/format"; import { useT } from "../../lib/i18n"; +/** One counter. Declared at module scope so a store update re-renders it in place + * instead of remounting the button under the user's focus. */ +function Counter({ + num, + den, + pct, + label, + cls, + view, +}: { + num: number; + den?: number; + /** Optional share (0..1) shown next to the fraction — e.g. "3/50 · 6%". */ + pct?: number; + label: string; + cls?: string; + view: PlacesView; +}) { + const t = useT(); + const openPlaces = useUi((s) => s.openPlaces); + // A tiny-but-nonzero coverage rounds to "0%", which reads as "none visited" + // even after you've been somewhere. Floor it to "<1%" (same as the hero). + const pctLabel = pct != null ? formatPercentFloor(pct) : null; + const aria = + pct != null + ? t("statStrip.visitedAria", { + num: formatInt(num), + den: formatInt(den ?? num), + label, + pct: pctLabel ?? formatPercent(pct), + }) + : t("statStrip.openAria", { label }); + return ( + + ); +} + /** Compact counter strip. Every counter is a shortcut: tap it to open the * matching Places view (been → visited, want → wishlist, fav → favourites…). */ export function StatStrip() { @@ -15,7 +71,6 @@ export function StatStrip() { const ref = useMemo(() => getReferenceData(), []); const visits = useVisits((s) => s.visits); const scope = useSettings((s) => s.countryScope); - const openPlaces = useUi((s) => s.openPlaces); const stats = useMemo(() => { const cov = computeCoverage(visits, ref, scope); @@ -28,58 +83,6 @@ export function StatStrip() { return { cov, want, fav }; }, [visits, ref, scope]); - function Counter({ - num, - den, - pct, - label, - cls, - view, - }: { - num: number; - den?: number; - /** Optional share (0..1) shown next to the fraction — e.g. "3/50 · 6%". */ - pct?: number; - label: string; - cls?: string; - view: PlacesView; - }) { - // A tiny-but-nonzero coverage rounds to "0%", which reads as "none visited" - // even after you've been somewhere. Floor it to "<1%" (same as the hero). - const pctLabel = pct != null ? formatPercentFloor(pct) : null; - const aria = - pct != null - ? t("statStrip.visitedAria", { - num: formatInt(num), - den: formatInt(den ?? num), - label, - pct: pctLabel ?? formatPercent(pct), - }) - : t("statStrip.openAria", { label }); - return ( - - ); - } - return (
{ + cleanup(); + useVisits.setState({ visits: [] }); +}); + +const wish = (id: string): Visit => ({ + visitId: `v-${id}`, + place: { kind: "country", id, name: id, countryId: id }, + date: null, + note: null, + status: "wishlist", + favorite: false, + addedAt: new Date().toISOString(), +}); + +describe("StatStrip", () => { + it("keeps its counter buttons (and focus) across a store update", () => { + render(); + const before = screen.getAllByRole("button")[0]!; + before.focus(); + act(() => useVisits.setState({ visits: [wish("FR")] })); + const after = screen.getAllByRole("button")[0]!; + expect(after).toBe(before); + expect(document.activeElement).toBe(before); + }); +}); From c433cc334ff9419a866a4c0520c61dc9f754da18 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:56:43 +0900 Subject: [PATCH 004/102] Guides: show the overview of the place on screen Moving a city or country page to another place kept the previous place's overview and full guide. GuideContent reads its saved guides in useState initialisers, which run once, and the page stays mounted across the move. GuideSection now keys GuideContent by the guide's country and title. --- .../src/features/guides/GuideButton.tsx | 8 ++++- .../tests/unit/guideSection.spec.tsx | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 apps/postcards/tests/unit/guideSection.spec.tsx diff --git a/apps/postcards/src/features/guides/GuideButton.tsx b/apps/postcards/src/features/guides/GuideButton.tsx index a3f2ada..53752a1 100644 --- a/apps/postcards/src/features/guides/GuideButton.tsx +++ b/apps/postcards/src/features/guides/GuideButton.tsx @@ -94,7 +94,13 @@ export function GuideSection({ place }: { place: PlaceRef }) { return (

{t("guide.sectionTitle")}

- + {/* Keyed by place: the page stays mounted when it moves to another place, and + GuideContent seeds its saved overview and guide from storage on mount. */} +
); } diff --git a/apps/postcards/tests/unit/guideSection.spec.tsx b/apps/postcards/tests/unit/guideSection.spec.tsx new file mode 100644 index 0000000..1300620 --- /dev/null +++ b/apps/postcards/tests/unit/guideSection.spec.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, act, cleanup } from "@testing-library/react"; +import { GuideSection } from "../../src/features/guides/GuideButton"; +import { useSettings } from "../../src/lib/store/useSettings"; +import { getReferenceData } from "../../src/lib/reference/referenceData"; +import type { PlaceRef } from "../../src/lib/schema/models"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("GuideSection", () => { + it("shows the saved overview of the place it is given, not the previous one", () => { + act(() => useSettings.setState({ autoLoadGuides: false, offlineMode: true })); + const ref = getReferenceData(); + const paris = ref.searchCities("Paris")[0]!; + const lyon = ref.searchCities("Lyon")[0]!; + const save = (title: string, extract: string) => + localStorage.setItem( + `postcards-guide:wikivoyage:FR:${title}`, + JSON.stringify({ title, extract, url: "https://example.org", attribution: "Wikivoyage" }), + ); + save(paris.name, "PARIS-EXTRACT"); + save(lyon.name, "LYON-EXTRACT"); + const p = (c: typeof paris): PlaceRef => ({ kind: "city", id: c.id, name: c.name, countryId: "FR" }); + const { rerender, container } = render(); + expect(container.textContent).toContain("PARIS-EXTRACT"); + // A city page stays mounted while it moves to a nearby place. + rerender(); + expect(container.textContent).toContain("LYON-EXTRACT"); + expect(container.textContent).not.toContain("PARIS-EXTRACT"); + }); +}); From 0c800dce30f188ad429f92ab059c7e92528c9947 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:57:23 +0900 Subject: [PATCH 005/102] Places: list every city you logged under Visited, Want and Favourites The Cities browse with a personal status scanned only the 2000 most populous cities, so a visited or wished-for small town never showed. Those views now start from the cities you hold a record for, ordered by population like the pool. --- .../src/features/visits/browseList.ts | 20 ++++++++++++++-- apps/postcards/tests/unit/browseList.spec.ts | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/postcards/src/features/visits/browseList.ts b/apps/postcards/src/features/visits/browseList.ts index 2b7ba58..7cf2038 100644 --- a/apps/postcards/src/features/visits/browseList.ts +++ b/apps/postcards/src/features/visits/browseList.ts @@ -64,6 +64,19 @@ function sortedMonuments(ref: ReferenceData): HeritageSite[] { return sorted; } +/** The cities you hold a record for, most-populous first like the pool: the + * visited / wishlist / favourites views list YOUR places, which the populous pool + * would drop whenever one is a small town. */ +function recordedCities(ref: ReferenceData, visits: Visit[]): City[] { + const out = new Map(); + for (const v of visits) { + if (v.place.kind !== "city" || out.has(v.place.id)) continue; + const c = ref.cityById(v.place.id); + if (c) out.set(c.id, c); + } + return [...out.values()].sort((a, b) => (b.population ?? 0) - (a.population ?? 0)); +} + /** Reset the memoised pools (tests only). */ export function __resetBrowseCache(): void { cityPool = null; @@ -115,12 +128,15 @@ export function browseList( if (kind === "cities") { // Country drill-down browses ALL of that country's cities (already population- - // desc), not just the global top-2000 pool, so nothing is missing from it. + // desc), not just the global top-2000 pool, so nothing is missing from it; so do + // the views of your own records, from the cities you logged. const base: City[] = q ? ref.searchCities(q, 500) : filter.country ? ref.citiesOf(filter.country) - : populousCities(ref); + : status === "visited" || status === "wishlist" || status === "favorites" + ? recordedCities(ref, visits) + : populousCities(ref); for (const c of base) { if (!countryOk(c.countryIso2)) continue; if (!continentOk(c.countryIso2)) continue; diff --git a/apps/postcards/tests/unit/browseList.spec.ts b/apps/postcards/tests/unit/browseList.spec.ts index c8efda2..9adfb70 100644 --- a/apps/postcards/tests/unit/browseList.spec.ts +++ b/apps/postcards/tests/unit/browseList.spec.ts @@ -3,6 +3,7 @@ import { browseList, __resetBrowseCache } from "../../src/features/visits/browse import { DEFAULT_FILTERS } from "../../src/lib/store/useFilters"; import type { ReferenceData, City, HeritageSite, Airport, Country } from "../../src/lib/reference/types"; import type { Visit } from "../../src/lib/schema/models"; +import { getReferenceData } from "../../src/lib/reference/referenceData"; const cities: City[] = [ { id: "paris", name: "Paris", countryIso2: "FR", subdivisionId: null, lat: 48.85, lon: 2.35, population: 2_100_000 }, @@ -25,6 +26,7 @@ const countries: Record = { const lc = (s: string) => s.toLowerCase(); const ref = { allCities: () => cities, + cityById: (id: string) => cities.find((c) => c.id === id), allHeritage: () => heritage, allAirports: () => airports, citiesOf: (iso2: string) => @@ -134,3 +136,25 @@ describe("browseList — reference browse + personal status overlay (spec 018 US expect(browseList("airports", "all", F, ref, [], "", 1).hasMore).toBe(true); }); }); + +describe("browseList — your own cities beyond the populous pool", () => { + it("status = visited / wishlist / favorites list a small town you logged", () => { + const world = getReferenceData(); + const town = [...world.allCities()].sort((a, b) => (a.population ?? 0) - (b.population ?? 0))[0]!; + const v = (status: "visited" | "wishlist", favorite = false): Visit => + ({ + visitId: `v-${town.id}`, + place: { kind: "city", id: town.id, name: town.name, countryId: town.countryIso2 }, + status, + favorite, + }) as Visit; + expect(bl("cities", "visited", F, world, [v("visited")], "").map((r) => r.id)).toEqual([town.id]); + expect(bl("cities", "wishlist", F, world, [v("wishlist")], "").map((r) => r.id)).toEqual([town.id]); + expect(bl("cities", "favorites", F, world, [v("visited", true)], "").map((r) => r.id)).toEqual([town.id]); + }); + + it("orders your cities most-populous first, like the pool", () => { + const visits = [visit("city", "lyon", "visited"), visit("city", "tokyo", "visited")]; + expect(bl("cities", "visited", F, ref, visits, "").map((r) => r.id)).toEqual(["tokyo", "lyon"]); + }); +}); From e2b962b40a19d38f029288b6ee1eae20cc157e80 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:57:27 +0900 Subject: [PATCH 006/102] Android: name the app Postcards in the launcher The installed app showed as Place'Been under its icon and in the task switcher: the rename to Postcards changed capacitor.config.ts but not the Android string resources the launcher reads. --- .../app/src/main/res/values/strings.xml | 4 ++-- .../tests/unit/androidProject.spec.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 apps/postcards/tests/unit/androidProject.spec.ts diff --git a/apps/postcards/android/app/src/main/res/values/strings.xml b/apps/postcards/android/app/src/main/res/values/strings.xml index a553900..72ea333 100644 --- a/apps/postcards/android/app/src/main/res/values/strings.xml +++ b/apps/postcards/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - Place\'Been - Place\'Been + Postcards + Postcards coop.samourai.postcards coop.samourai.postcards diff --git a/apps/postcards/tests/unit/androidProject.spec.ts b/apps/postcards/tests/unit/androidProject.spec.ts new file mode 100644 index 0000000..3276b93 --- /dev/null +++ b/apps/postcards/tests/unit/androidProject.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import config from "../../capacitor.config"; + +// Vitest runs from apps/postcards (jsdom rewrites import.meta.url, so cwd it is). +const STRINGS = resolve(process.cwd(), "android/app/src/main/res/values/strings.xml"); + +// The launcher shows the Android string resources, not capacitor.config.ts, so a +// rename has to reach both. +describe("Android project", () => { + it("names the app in the launcher as capacitor.config.ts does", () => { + const xml = readFileSync(STRINGS, "utf8"); + const label = (name: string) => + xml.match(new RegExp(`([^<]*)`))?.[1]; + expect(label("app_name")).toBe(config.appName); + expect(label("title_activity_main")).toBe(config.appName); + }); +}); From 22ac4e30ef003ca7d6b984609919b9ca48216765 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:58:05 +0900 Subject: [PATCH 007/102] Dates: label months and weekdays in the zone they are built in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit West of UTC the trip composer listed "December" for month 01, a month-dated trip read as the month before, and the Monday-first journal calendar was headed Sun…Sat. Each label came from a date built at UTC midnight and formatted in local time; the composer now reuses the local month-name helper and the other two format in UTC. --- .../src/features/journal/JournalScreen.tsx | 2 +- .../src/features/travel/TripComposer.tsx | 6 +- .../postcards/src/features/travel/tripDate.ts | 2 +- apps/postcards/tests/unit/timeZones.spec.tsx | 61 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 apps/postcards/tests/unit/timeZones.spec.tsx diff --git a/apps/postcards/src/features/journal/JournalScreen.tsx b/apps/postcards/src/features/journal/JournalScreen.tsx index 5b9f070..80901db 100644 --- a/apps/postcards/src/features/journal/JournalScreen.tsx +++ b/apps/postcards/src/features/journal/JournalScreen.tsx @@ -321,7 +321,7 @@ function JournalCalendar({ ); // Localized weekday abbreviations, ordered from FIRST_DAY_OF_WEEK (2023-01-01 is a Sunday). const weekdays = useMemo(() => { - const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" }); + const fmt = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }); return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2023, 0, 1 + ((FIRST_DAY_OF_WEEK + i) % 7)))), ); diff --git a/apps/postcards/src/features/travel/TripComposer.tsx b/apps/postcards/src/features/travel/TripComposer.tsx index 0ec42da..41370d9 100644 --- a/apps/postcards/src/features/travel/TripComposer.tsx +++ b/apps/postcards/src/features/travel/TripComposer.tsx @@ -11,6 +11,7 @@ import { appendStop, moveStopTo, removeStopAt, setLegMode, type StopChain } from import { tripPathKm } from "./distance"; import { MODE_ORDER, MODE_GLYPH } from "./modes"; import { parseTripDate } from "./tripDate"; +import { monthName } from "./period"; const MONTHS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]; @@ -70,9 +71,6 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo const { km, unresolvedLegs } = useMemo(() => tripPathKm(stops, ref), [stops, ref]); const canSave = stops.length >= 2; - const monthName = (mm: string) => - new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(Date.UTC(2000, Number(mm) - 1, 1))); - function composeDate(): string | null { const y = year.trim(); if (!/^\d{4}$/.test(y)) return null; @@ -248,7 +246,7 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo {MONTHS.map((mm) => ( ))} diff --git a/apps/postcards/src/features/travel/tripDate.ts b/apps/postcards/src/features/travel/tripDate.ts index 7624068..07d436e 100644 --- a/apps/postcards/src/features/travel/tripDate.ts +++ b/apps/postcards/src/features/travel/tripDate.ts @@ -44,7 +44,7 @@ export function formatTripDate(s: TripDate, locale: string): string { if (!p) return ""; if (p.month == null) return String(p.year); if (p.day == null) { - return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short" }).format( + return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", timeZone: "UTC" }).format( new Date(Date.UTC(p.year, p.month - 1, 1)), ); } diff --git a/apps/postcards/tests/unit/timeZones.spec.tsx b/apps/postcards/tests/unit/timeZones.spec.tsx new file mode 100644 index 0000000..b002374 --- /dev/null +++ b/apps/postcards/tests/unit/timeZones.spec.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, within, cleanup } from "@testing-library/react"; +import { formatTripDate } from "../../src/features/travel/tripDate"; +import { TripComposer } from "../../src/features/travel/TripComposer"; +import { JournalScreen } from "../../src/features/journal/JournalScreen"; +import { useStories } from "../../src/lib/store/useStories"; +import { useTrips } from "../../src/lib/store/useTrips"; +import { useVisits } from "../../src/lib/store/useVisits"; +import type { Story } from "../../src/lib/schema/models"; + +// Calendar labels must not depend on the device's time zone. A date built at UTC +// midnight and formatted in local time reads as the day before west of UTC, so +// every case runs under a zone on each side of UTC (Node applies a TZ change at +// runtime); `TZ=America/New_York npx vitest run` exercises the same from outside. +const ZONES = ["UTC", "America/New_York", "Asia/Tokyo"]; + +describe.each(ZONES)("calendar labels in %s", (zone) => { + const saved = process.env.TZ; + beforeEach(() => { + process.env.TZ = zone; + }); + afterEach(() => { + cleanup(); + if (saved === undefined) delete process.env.TZ; + else process.env.TZ = saved; + }); + + it("a month-precision trip date shows its own month", () => { + expect(formatTripDate("2024-08", "en")).toBe("Aug 2024"); + expect(formatTripDate("2024-01", "en")).toBe("Jan 2024"); + }); + + it("the trip composer's month options name the month they save", () => { + useTrips.setState({ trips: [] }); + useVisits.setState({ visits: [] }); + render( {}} />); + const sel = screen.getByLabelText("Month") as HTMLSelectElement; + const opts = [...sel.options].slice(1).map((o) => `${o.value}=${o.textContent}`); + expect(opts[0]).toBe("01=January"); + expect(opts[11]).toBe("12=December"); + }); + + it("the Monday-first journal calendar is headed Mon…Sun", () => { + const story: Story = { + storyId: "s1", + place: { kind: "country", id: "FR", name: "France", countryId: "FR" }, + date: "2024-07-01", + title: "t", + text: "", + addedAt: "2024-07-01T00:00:00Z", + }; + useStories.setState({ loaded: true, stories: [story] }); + useVisits.setState({ visits: [] }); + render(); + fireEvent.click(screen.getByRole("button", { name: /Calendar/ })); + const heads = within(screen.getByRole("table")) + .getAllByRole("columnheader") + .map((th) => th.textContent); + expect(heads).toEqual(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]); + }); +}); From 1c59f3815fde9d33db7bb9d8167f9872f1ce3504 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:58:10 +0900 Subject: [PATCH 008/102] Android: sign every CI build with one key so updates install in place Each CI run generated a fresh debug keystore, so every APK carried a different certificate and versionCode 1: Android refused it as an update, and the only way forward was to uninstall, which deletes the app's data. With the keystore in repository secrets CI now builds a release APK signed with one stable key; versionCode is the run number and versionName the app version plus the short sha. Without the secrets it builds the debug APK as before. The rolling release moves to android-latest with a postcards.apk asset, install notes, and a URL that never changes. --- .github/workflows/android-apk.yml | 81 +++++++++++++++++++------ apps/postcards/android/app/build.gradle | 24 +++++++- docs/NATIVE-BUILDS.md | 70 ++++++++++++++++----- 3 files changed, 140 insertions(+), 35 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index a60e917..599cb96 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -1,8 +1,12 @@ -# Build an installable Android debug APK and upload it as a downloadable -# artifact — so an APK can be obtained with NO local Android toolchain: push (or -# run this workflow manually), open the run, and download `postcards-debug-apk`. +# Build an installable Android APK and upload it as a downloadable artifact — so +# an APK can be obtained with NO local Android toolchain: push (or run this +# workflow manually), open the run, and download `postcards-apk`. On main it is +# also published at a permanent download URL (the release step below). +# With the signing secrets set (docs/NATIVE-BUILDS.md) the APK is a release build +# signed with one stable key, so each one installs as an update over the last; +# without them (forks) it is the debug build, signed with a throwaway key. # Local headless build: `pnpm --filter postcards apk:debug` (see docs/NATIVE-BUILDS.md). -name: Android APK (debug) +name: Android APK on: push: @@ -46,31 +50,72 @@ jobs: - name: Sync the Android project # Copies the fresh dist/ into the committed android/ project + updates plugins. run: pnpm --filter postcards exec cap sync android - - name: Assemble the debug APK + - name: Assemble the APK + id: apk working-directory: apps/postcards/android + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + POSTCARDS_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + POSTCARDS_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + POSTCARDS_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew --no-daemon assembleDebug + # The run number only grows, so every APK has a higher versionCode than + # the one it replaces. + version="$(node -p "require('../package.json').version")-${GITHUB_SHA::7}" + props=(-PversionCode="$GITHUB_RUN_NUMBER" -PversionName="$version") + if [ -n "$KEYSTORE_BASE64" ]; then + export POSTCARDS_KEYSTORE_FILE="$RUNNER_TEMP/postcards.jks" + printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$POSTCARDS_KEYSTORE_FILE" + ./gradlew --no-daemon assembleRelease "${props[@]}" + cp app/build/outputs/apk/release/app-release.apk "$RUNNER_TEMP/postcards.apk" + echo "signing=release" >> "$GITHUB_OUTPUT" + else + ./gradlew --no-daemon assembleDebug "${props[@]}" + cp app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/postcards.apk" + echo "signing=debug" >> "$GITHUB_OUTPUT" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Upload the APK uses: actions/upload-artifact@v4 with: - name: postcards-debug-apk - path: apps/postcards/android/app/build/outputs/apk/debug/app-debug.apk + name: postcards-apk + path: ${{ runner.temp }}/postcards.apk if-no-files-found: error # Also publish it to the Releases page so it's downloadable without opening - # the Actions run. One rolling prerelease ("debug-latest") holds the newest - # build: delete + recreate so its tag always points at the current commit. + # the Actions run. One rolling release ("android-latest") holds the newest + # build: delete + recreate so its tag always points at the current commit, + # while the download URL stays the same: + # https://github.com///releases/download/android-latest/postcards.apk # main only — feature-branch pushes still get the run artifact above. - - name: Publish the APK to the rolling debug release + - name: Publish the APK to the rolling release if: github.ref == 'refs/heads/main' env: GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.apk.outputs.version }} + SIGNING: ${{ steps.apk.outputs.signing }} run: | - gh release delete debug-latest --yes --cleanup-tag 2>/dev/null || true - gh release create debug-latest \ - "apps/postcards/android/app/build/outputs/apk/debug/app-debug.apk#postcards-debug.apk" \ - --title "Latest debug APK" \ - --notes "Automated debug build from ${{ github.sha }}. Debug/unsigned — not for production." \ - --prerelease \ - --target "${{ github.sha }}" + if [ "$SIGNING" = release ]; then + updates="Each new version installs over the previous one and keeps your places." + else + updates="This is a debug build signed with a one-off key: to install a newer one, export your data (Settings, Your data), uninstall this one, install the new APK and import the file." + fi + cat > "$RUNNER_TEMP/notes.md" </dev/null || true + gh release create android-latest "$RUNNER_TEMP/postcards.apk" \ + --title "Postcards for Android" \ + --notes-file "$RUNNER_TEMP/notes.md" \ + --latest \ + --target "$GITHUB_SHA" diff --git a/apps/postcards/android/app/build.gradle b/apps/postcards/android/app/build.gradle index 1579319..a0ee811 100644 --- a/apps/postcards/android/app/build.gradle +++ b/apps/postcards/android/app/build.gradle @@ -7,8 +7,10 @@ android { applicationId "coop.samourai.postcards" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + // CI passes -PversionCode (the run number, so each build installs as an + // update over the last) and -PversionName (app version + commit). + versionCode((project.findProperty("versionCode") ?: "1") as Integer) + versionName(project.findProperty("versionName") ?: "1.0") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -16,8 +18,26 @@ android { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + // One stable key signs every release build, so Android accepts each new APK + // as an update and keeps the app's data. CI writes the keystore from + // repository secrets (docs/NATIVE-BUILDS.md); without them there is no + // release signing and CI builds the debug APK instead. + def keystoreFile = System.getenv("POSTCARDS_KEYSTORE_FILE") + signingConfigs { + if (keystoreFile) { + release { + storeFile file(keystoreFile) + storePassword System.getenv("POSTCARDS_KEYSTORE_PASSWORD") + keyAlias System.getenv("POSTCARDS_KEY_ALIAS") + keyPassword System.getenv("POSTCARDS_KEY_PASSWORD") + } + } + } buildTypes { release { + if (keystoreFile) { + signingConfig signingConfigs.release + } minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } diff --git a/docs/NATIVE-BUILDS.md b/docs/NATIVE-BUILDS.md index e9fe375..bd8ec75 100644 --- a/docs/NATIVE-BUILDS.md +++ b/docs/NATIVE-BUILDS.md @@ -15,20 +15,60 @@ thin shells that load it. ## Android — get an APK -### Easiest: download a debug APK from CI (no local Android toolchain) +### Easiest: download the APK -The [`Android APK (debug)`](../.github/workflows/android-apk.yml) workflow builds an installable debug -APK on every push (and on demand) and uploads it as an artifact: +The newest build of `main` is always at one permanent URL: -1. Push to `main` or any `claude/**` branch — or run the workflow manually (Actions → **Android APK - (debug)** → *Run workflow*). -2. Open the finished run and download the **`postcards-debug-apk`** artifact. -3. Unzip and install: `adb install app-debug.apk` (or copy to the phone and open it; you'll need - "install unknown apps" enabled since it's a debug build). +**** -The APK is **debug-signed** with the auto-generated Android debug keystore (fine for sideloading, not -for the Play Store) — the signing cert differs per machine/run, so uninstall an older copy if Android -refuses to update over it. +Open it on the phone, open the downloaded file, and allow "install unknown apps" for the browser if +Android asks. The [`Android APK`](../.github/workflows/android-apk.yml) workflow builds it on every +push to `main` and republishes the rolling `android-latest` release, so the tag always points at the +current commit while the URL never changes. It links to the tag rather than to +`releases/latest/download/…`, which follows whichever release GitHub marks latest and breaks the day +a versioned release without a `postcards.apk` asset is published. + +Every push (to `main`, any `claude/**` branch, or a manual run from Actions → **Android APK** → +*Run workflow*) also uploads the APK as the run's **`postcards-apk`** artifact. + +Each build carries `versionCode` = the workflow run number (it only grows) and `versionName` = +`-`. Locally both default to `1` / `1.0`; pass +`-PversionCode=… -PversionName=…` to `gradlew` to set them. + +### Signing: one key, so updates install over the previous app + +Android installs an APK as an update only when it is signed with the same key as the installed app +and its `versionCode` is not lower; otherwise the user must uninstall first, and uninstalling deletes the +app's data (`allowBackup` is off). The debug keystore is generated fresh on every CI runner, so debug +builds cannot update one another. + +When the four repository secrets below exist, CI builds `assembleRelease` signed with that key. +Without them (forks, or before the secrets are set) it builds the debug APK, as before. Create the +key once, keep the `.jks` file and its password somewhere safe outside the repo (losing it means +every user has to uninstall to get the next version), and set the secrets: + +```bash +# PKCS12 keystores use one password for the store and the key. +read -rs -p "Keystore password: " KS_PASS; echo +keytool -genkeypair -v -storetype PKCS12 -keystore postcards-release.jks \ + -alias postcards -keyalg RSA -keysize 4096 -validity 10000 \ + -dname "CN=Postcards, O=offware-apps" \ + -storepass "$KS_PASS" -keypass "$KS_PASS" + +base64 -w0 postcards-release.jks | gh secret set ANDROID_KEYSTORE_BASE64 -R offware-apps/Postcards +printf '%s' "$KS_PASS" | gh secret set ANDROID_KEYSTORE_PASSWORD -R offware-apps/Postcards +printf '%s' "$KS_PASS" | gh secret set ANDROID_KEY_PASSWORD -R offware-apps/Postcards +printf '%s' postcards | gh secret set ANDROID_KEY_ALIAS -R offware-apps/Postcards +unset KS_PASS +``` + +(`base64 -w0` is GNU; on macOS use `base64 -i postcards-release.jks`.) The first signed APK cannot +install over a debug build already on a phone: export your data (Settings → Your data), uninstall, +install the signed APK, import the file. Every later build updates in place. + +To sign a release build locally with the same key, export `POSTCARDS_KEYSTORE_FILE` (path to the +`.jks`), `POSTCARDS_KEYSTORE_PASSWORD`, `POSTCARDS_KEY_ALIAS` and `POSTCARDS_KEY_PASSWORD`, then run +`./gradlew assembleRelease` in `android/`. ### Build the APK locally, headless (no Android Studio) @@ -81,7 +121,7 @@ pnpm --filter postcards native:ios - **Shared Offline Map Store.** The `OfflineMapStore` seam (see [`OFFLINE-MAPS.md`](OFFLINE-MAPS.md)) is where a native `SharedOfflineMapStore` plugin (iOS App Group / Android SAF) plugs in, so map packs are device-global across the ecosystem. -- **CI.** The `Android APK (debug)` workflow produces an installable **debug** APK on GitHub's - Ubuntu runners (JDK 17 + the runner's Android SDK; `gradlew` self-bootstraps Gradle). A **release** - AAB/APK still needs a signing key (store it as an encrypted secret) and, for iOS, a macOS runner - with an Apple signing team — out of scope for the debug artifact above. +- **CI.** The `Android APK` workflow builds on GitHub's Ubuntu runners (JDK 17 + the runner's + Android SDK; `gradlew` self-bootstraps Gradle): a signed release APK when the signing secrets are + set, the debug APK otherwise. A Play Store AAB and iOS (a macOS runner with an Apple signing team) + are out of scope. From 6b8649a8e086f13b20137554e8fae5d4f696c806 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:58:30 +0900 Subject: [PATCH 009/102] Places: show the monument category as a filter chip and apply it at once A category picked under Monuments persists and keeps narrowing the saved places list, but no chip named it, so it could be neither seen nor cleared from any other view. The list also ignored a category change until another filter moved, since its memo did not depend on it. The category now gets a chip on Places (the map, which ignores it, hides it), and both memos depend on it. --- .../src/features/filter/applyFilters.ts | 1 + apps/postcards/src/features/map/MapScreen.tsx | 2 + .../src/features/visits/PlacesScreen.tsx | 2 + .../postcards/tests/unit/applyFilters.spec.ts | 4 ++ .../tests/unit/placesScreen.spec.tsx | 57 +++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 apps/postcards/tests/unit/placesScreen.spec.tsx diff --git a/apps/postcards/src/features/filter/applyFilters.ts b/apps/postcards/src/features/filter/applyFilters.ts index 2b3a712..a4c7691 100644 --- a/apps/postcards/src/features/filter/applyFilters.ts +++ b/apps/postcards/src/features/filter/applyFilters.ts @@ -98,6 +98,7 @@ export function activeChips(s: FilterState, t: TFunction, ref?: ReferenceData): chips.push({ field: "date", label: y ?? [s.date.from, s.date.to].filter(Boolean).join(" – ") }); } if (s.folder) chips.push({ field: "folder", label: `📁 ${s.folder}` }); + if (s.category) chips.push({ field: "category", label: t(`filter.category.${s.category}` as MessageKey) }); if (s.mode !== "all") { chips.push({ field: "mode", label: t(`filter.mode.${s.mode}` as MessageKey) }); } diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx index fea9c6b..b03ac39 100644 --- a/apps/postcards/src/features/map/MapScreen.tsx +++ b/apps/postcards/src/features/map/MapScreen.tsx @@ -58,6 +58,8 @@ const MAP_HIDDEN_FIELDS: (keyof FilterState)[] = [ // One-country drill-down is a lists action (set from the Stats card); on the map // you just pan to the country, so it neither filters the markers nor shows a chip. "country", + // Monument category is a Places list dimension; the map's markers ignore it. + "category", // Place-kind mode is its own prominent pill now, not a filter — its state is // visible in the pill itself, so it must not double up as a summary chip or // light the Filter badge. diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 4d40588..2245a74 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -584,6 +584,7 @@ export function PlacesScreen() { filters.minPop, filters.sort, filters.mode, + filters.category, filters.favoritesOnly, filters.hasPhoto, filters.hasNote, @@ -701,6 +702,7 @@ export function PlacesScreen() { filters.folder, filters.minPop, filters.sort, + filters.category, filters.favoritesOnly, filters.hasPhoto, filters.hasNote, diff --git a/apps/postcards/tests/unit/applyFilters.spec.ts b/apps/postcards/tests/unit/applyFilters.spec.ts index 9b8aa82..bafb2d6 100644 --- a/apps/postcards/tests/unit/applyFilters.spec.ts +++ b/apps/postcards/tests/unit/applyFilters.spec.ts @@ -133,4 +133,8 @@ describe("activeChips", () => { expect(fields).toContain("favoritesOnly"); expect(chips.find((c) => c.field === "minPop")!.label).toBe("1M+"); }); + it("names a monument category, so a persisted one can be seen and cleared", () => { + const chips = activeChips(st({ category: "natural" }), t); + expect(chips).toEqual([{ field: "category", label: "filter.category.natural" }]); + }); }); diff --git a/apps/postcards/tests/unit/placesScreen.spec.tsx b/apps/postcards/tests/unit/placesScreen.spec.tsx new file mode 100644 index 0000000..0ad7bcd --- /dev/null +++ b/apps/postcards/tests/unit/placesScreen.spec.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { render, screen, act, cleanup } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PlacesScreen } from "../../src/features/visits/PlacesScreen"; +import { useVisits } from "../../src/lib/store/useVisits"; +import { useFilters, DEFAULT_FILTERS } from "../../src/lib/store/useFilters"; +import { initReferenceDataSync } from "../../src/lib/reference/referenceData"; +import type { Visit } from "../../src/lib/schema/models"; +import type { Airport, City, HeritageSite, Subdivision } from "../../src/lib/reference/types"; + +const here = dirname(fileURLToPath(import.meta.url)); +const read = (name: string) => JSON.parse(readFileSync(join(here, "..", "..", "public", "reference", name), "utf8")); +const heritage = read("heritage.json") as HeritageSite[]; +initReferenceDataSync( + read("cities-all.json") as City[], + read("subdivisions.json") as Subdivision[], + read("airports.json") as Airport[], + heritage, +); + +const site = (category: string) => heritage.find((h) => h.category === category)!; +const visitOf = (h: HeritageSite): Visit => ({ + visitId: `v-${h.id}`, + place: { kind: "heritage", id: h.id, name: h.name, countryId: h.countryIso2 }, + date: null, + note: null, + status: "visited", + favorite: false, + addedAt: new Date().toISOString(), +}); + +beforeEach(() => { + localStorage.clear(); + useFilters.setState({ ...DEFAULT_FILTERS }); +}); +afterEach(() => { + cleanup(); + useVisits.setState({ visits: [] }); +}); + +describe("PlacesScreen monument category", () => { + it("narrows your list as soon as it changes, with a chip to clear it", () => { + const cultural = site("cultural"); + const natural = site("natural"); + useVisits.setState({ visits: [visitOf(cultural), visitOf(natural)] }); + render(); + expect(document.body.textContent).toContain(cultural.name); + act(() => useFilters.getState().set({ category: "natural" })); + expect(document.body.textContent).not.toContain(cultural.name); + expect(document.body.textContent).toContain(natural.name); + act(() => screen.getByRole("button", { name: "Remove Natural filter" }).click()); + expect(useFilters.getState().category).toBe(""); + expect(document.body.textContent).toContain(cultural.name); + }); +}); From d72ffb7bb5a2711c135b715acb0e4de64e38977e Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:59:24 +0900 Subject: [PATCH 010/102] Dates: show a month or year date at its own precision formatDate read every date as a day, so a trip dated 2024-03 showed as "Mar 1, 2024" and 2024 as "Jan 1, 2024" in the publish trip picker, the Markdown export and the publish summary, and an impossible 2024-02-31 rolled into March. It now labels a month or a year as such, formats in UTC, and passes an impossible day through as typed; parseTripDate rejects a day its month lacks. --- .../postcards/src/features/travel/tripDate.ts | 21 ++++-------- apps/postcards/src/lib/format/format.ts | 22 ++++++++++--- apps/postcards/src/lib/schema/helpers.ts | 20 ++++++++++++ apps/postcards/tests/unit/format.spec.ts | 32 +++++++++++++++++-- apps/postcards/tests/unit/tripDate.spec.ts | 4 +++ 5 files changed, 79 insertions(+), 20 deletions(-) diff --git a/apps/postcards/src/features/travel/tripDate.ts b/apps/postcards/src/features/travel/tripDate.ts index 07d436e..4d13872 100644 --- a/apps/postcards/src/features/travel/tripDate.ts +++ b/apps/postcards/src/features/travel/tripDate.ts @@ -1,4 +1,5 @@ import { formatDate } from "../../lib/format/format"; +import { isCalendarDate } from "../../lib/schema/helpers"; // Approximate ("vague") trip dates (spec 019). A trip date is deliberately coarse: // a full day `YYYY-MM-DD`, a month `YYYY-MM`, a year `YYYY`, or nothing. These pure @@ -18,7 +19,8 @@ export interface ParsedTripDate { day: number | null; } -/** Parse a trip date into its known parts, or null if empty/malformed. */ +/** Parse a trip date into its known parts, or null if empty, malformed or not a + * real calendar date (month 13, Feb 30). */ export function parseTripDate(s: TripDate): ParsedTripDate | null { if (!s) return null; const m = RE.exec(s); @@ -26,8 +28,7 @@ export function parseTripDate(s: TripDate): ParsedTripDate | null { const year = Number(m[1]); const month = m[2] != null ? Number(m[2]) : null; const day = m[3] != null ? Number(m[3]) : null; - if (month != null && (month < 1 || month > 12)) return null; - if (day != null && (day < 1 || day > 31)) return null; + if (!isCalendarDate(s)) return null; return { year, month, day }; } @@ -37,18 +38,10 @@ export function isValidTripDate(s: string): boolean { } /** Human label for the granularity present: a year "2024", a month "Aug 2024", or a - * full day formatted like every other date in the app (via `formatDate`), and "" for - * undated. Keeps full-date display identical to the rest of the UI. */ + * full day, all via the app-wide `formatDate` so trip rows match visits/journal, + * and "" for undated or malformed. */ export function formatTripDate(s: TripDate, locale: string): string { - const p = parseTripDate(s); - if (!p) return ""; - if (p.month == null) return String(p.year); - if (p.day == null) { - return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", timeZone: "UTC" }).format( - new Date(Date.UTC(p.year, p.month - 1, 1)), - ); - } - // A full day reuses the app-wide date format so trip rows match visits/journal. + if (!parseTripDate(s)) return ""; return formatDate(s, locale); } diff --git a/apps/postcards/src/lib/format/format.ts b/apps/postcards/src/lib/format/format.ts index b2cbe02..cd80fb6 100644 --- a/apps/postcards/src/lib/format/format.ts +++ b/apps/postcards/src/lib/format/format.ts @@ -7,6 +7,8 @@ // locale is undefined and Intl falls back to the environment default, exactly as // before — so existing behaviour and snapshots are unchanged. +import { isCalendarDate } from "../schema/helpers"; + let activeLocale: string | undefined; /** Set the locale used by the formatters when no explicit locale is passed. */ @@ -52,10 +54,22 @@ export function formatPercentFloor(value: number, locale = activeLocale): string return value > 0 && s === formatPercent(0, locale) ? "<1%" : s; } -/** ISO YYYY-MM-DD -> localized date; passthrough if unparseable. */ +/** A calendar date -> localized label at the precision it carries: a day + * `YYYY-MM-DD` ("Aug 12, 2024"), a month `YYYY-MM` ("Aug 2024") or a year `YYYY` + * ("2024"), so a vague trip date never gains a day it doesn't have. Formatted in + * UTC, so the label is the same in every time zone. Passthrough if unparseable or + * not a real day (Feb 30 is shown as typed, not rolled into March). */ export function formatDate(iso: string | null, locale = activeLocale): string { if (!iso) return ""; - const d = new Date(iso + "T00:00:00"); - if (Number.isNaN(d.getTime())) return iso; - return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d); + if (!isCalendarDate(iso)) return iso; + const [year, month, day] = iso.split("-").map(Number) as [number, number?, number?]; + if (month == null) return String(year); + const d = new Date(0); + d.setUTCFullYear(year, month - 1, day ?? 1); + return new Intl.DateTimeFormat( + locale, + day == null + ? { year: "numeric", month: "short", timeZone: "UTC" } + : { dateStyle: "medium", timeZone: "UTC" }, + ).format(d); } diff --git a/apps/postcards/src/lib/schema/helpers.ts b/apps/postcards/src/lib/schema/helpers.ts index 9f52078..11d2b2d 100644 --- a/apps/postcards/src/lib/schema/helpers.ts +++ b/apps/postcards/src/lib/schema/helpers.ts @@ -94,3 +94,23 @@ export function backfillUpdatedAt new Date().toISOString(); + +/** Days in a month (1–12) of a proleptic Gregorian year. Pure arithmetic, so it + * never depends on the device's time zone. */ +export function daysInMonth(year: number, month: number): number { + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0; +} + +/** Whether a `YYYY`, `YYYY-MM` or `YYYY-MM-DD` string names a real year, month or + * day: no month 13, no Feb 30. Anything else is false. */ +export function isCalendarDate(s: string): boolean { + const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s); + if (!m) return false; + if (m[2] == null) return true; + const month = Number(m[2]); + if (month < 1 || month > 12) return false; + if (m[3] == null) return true; + const day = Number(m[3]); + return day >= 1 && day <= daysInMonth(Number(m[1]), month); +} diff --git a/apps/postcards/tests/unit/format.spec.ts b/apps/postcards/tests/unit/format.spec.ts index 0797e39..2cdc145 100644 --- a/apps/postcards/tests/unit/format.spec.ts +++ b/apps/postcards/tests/unit/format.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { countryFlag } from "../../src/lib/format/format"; +import { describe, it, expect, afterEach } from "vitest"; +import { countryFlag, formatDate } from "../../src/lib/format/format"; describe("countryFlag", () => { it("builds the regional-indicator pair for a country", () => { @@ -11,3 +11,31 @@ describe("countryFlag", () => { expect(countryFlag("RE")).toBe("🇷🇪"); // Réunion }); }); + +describe("formatDate", () => { + const saved = process.env.TZ; + afterEach(() => { + if (saved === undefined) delete process.env.TZ; + else process.env.TZ = saved; + }); + + it("keeps the precision a vague date carries (never invents a day)", () => { + expect(formatDate("2024-08-12", "en")).toBe("Aug 12, 2024"); + expect(formatDate("2024-03", "en")).toBe("Mar 2024"); + expect(formatDate("2024", "en")).toBe("2024"); + expect(formatDate(null, "en")).toBe(""); + }); + + it("shows an impossible or malformed date as typed rather than rolling it", () => { + expect(formatDate("2024-02-31", "en")).toBe("2024-02-31"); + expect(formatDate("2024-13-45", "en")).toBe("2024-13-45"); + expect(formatDate("soon", "en")).toBe("soon"); + }); + + // Node applies a TZ change at runtime, so each zone runs in this one process. + it.each(["UTC", "America/New_York", "Asia/Tokyo"])("labels the same day in %s", (zone) => { + process.env.TZ = zone; + expect(formatDate("2024-01-01", "en")).toBe("Jan 1, 2024"); + expect(formatDate("2024-01", "en")).toBe("Jan 2024"); + }); +}); diff --git a/apps/postcards/tests/unit/tripDate.spec.ts b/apps/postcards/tests/unit/tripDate.spec.ts index 1982553..31153d5 100644 --- a/apps/postcards/tests/unit/tripDate.spec.ts +++ b/apps/postcards/tests/unit/tripDate.spec.ts @@ -20,6 +20,10 @@ describe("tripDate — approximate trip dates (spec 019)", () => { expect(isValidTripDate("2024-13")).toBe(false); // no month 13 expect(isValidTripDate("2024-00")).toBe(false); expect(isValidTripDate("2024-08-40")).toBe(false); // no day 40 + expect(isValidTripDate("2024-02-30")).toBe(false); // no Feb 30 + expect(isValidTripDate("2023-02-29")).toBe(false); // not a leap year + expect(isValidTripDate("2024-02-29")).toBe(true); + expect(formatTripDate("2024-02-31", "en")).toBe(""); // never rolled into March expect(isValidTripDate("abc")).toBe(false); expect(isValidTripDate("24-08")).toBe(false); }); From c57e010db2b5612fb3afb339a75d6b2777347045 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:00:20 +0900 Subject: [PATCH 011/102] Schema: accept only real calendar dates The file schema checked a date's shape only, so 2024-13-45 validated on a visit, a trip or a story. A story date must now be a real day; a visit or trip date that is not one loads as undated rather than failing the file, since the CSV import once stored such dates and a file the app wrote must still load. --- apps/postcards/src/lib/schema/models.ts | 18 +++++++--- apps/postcards/tests/unit/schema.spec.ts | 43 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/apps/postcards/src/lib/schema/models.ts b/apps/postcards/src/lib/schema/models.ts index 75612e4..83daeab 100644 --- a/apps/postcards/src/lib/schema/models.ts +++ b/apps/postcards/src/lib/schema/models.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { sanitizeText } from "./sanitize"; -import { FORMAT, MAX_PHOTOS_PER_STORY, MAX_PHOTOS_PER_VISIT } from "./helpers"; +import { FORMAT, MAX_PHOTOS_PER_STORY, MAX_PHOTOS_PER_VISIT, isCalendarDate } from "./helpers"; // Canonical, versioned schema for the portable data file. // Single source of truth: these Zod models generate TS types AND the published @@ -98,6 +98,13 @@ const optionalLabel = (max = 80) => }) .optional(); +/** An optional visit or trip date that has the right shape but names no real day + * (2024-13-45, 2024-02-30) loads as undated instead of failing the whole file: + * the CSV import once stored such dates, so files the app itself wrote carry them, + * and a date nothing can place on a calendar is no date. */ +const calendarDateOrNull = (v: string | null | undefined): string | null => + v != null && isCalendarDate(v) ? v : null; + export const PhotoSchema = z .object({ src: photoDataUrl, @@ -117,7 +124,7 @@ export const VisitSchema = z.object({ .regex(/^\d{4}-\d{2}-\d{2}$/) .nullable() .optional() - .transform((v) => v ?? null), + .transform(calendarDateOrNull), note: nullableSanitized(2000), /** * Legacy single "postcard" photo (schema ≤ v2). Kept so v1/v2 files import @@ -200,7 +207,7 @@ export const TripSchema = z .regex(/^\d{4}(-\d{2}(-\d{2})?)?$/) .nullable() .optional() - .transform((v) => v ?? null), + .transform(calendarDateOrNull), carrier: nullableSanitized(120), note: nullableSanitized(2000), addedAt: z.string().datetime({ offset: true }), @@ -220,7 +227,10 @@ export const StorySchema = z storyId: idString, place: PlaceRefSchema, /** The day the story is about — required, unlike a visit's optional date. */ - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine(isCalendarDate, { message: "date must be a real calendar day" }), // Title AND text are both optional so a journal entry can be image-only. Each // stays a (possibly empty) string — no key ripple for consumers — and the // whole-story refine below still requires a title, some text, OR a photo, so a diff --git a/apps/postcards/tests/unit/schema.spec.ts b/apps/postcards/tests/unit/schema.spec.ts index 6afdd6c..75d6608 100644 --- a/apps/postcards/tests/unit/schema.spec.ts +++ b/apps/postcards/tests/unit/schema.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { z } from "zod"; -import { PostcardsFileSchema, VisitSchema } from "../../src/lib/schema/models"; +import { PostcardsFileSchema, StorySchema, TripSchema, VisitSchema } from "../../src/lib/schema/models"; function baseVisit() { return { @@ -78,6 +78,47 @@ describe("PostcardsFileSchema", () => { expect(JSON.stringify(blank).includes("folder")).toBe(false); }); + it("keeps real calendar dates and loads an impossible visit or trip date as undated", () => { + expect(VisitSchema.parse({ ...baseVisit(), date: "2024-02-29" }).date).toBe("2024-02-29"); + expect(VisitSchema.parse({ ...baseVisit(), date: "2024-13-45" }).date).toBeNull(); + expect(VisitSchema.parse({ ...baseVisit(), date: "2023-02-29" }).date).toBeNull(); + const trip = { + tripId: "t", + from: { kind: "country", id: "FR", name: "France", countryId: "FR" }, + to: { kind: "country", id: "JP", name: "Japan", countryId: "JP" }, + mode: "flight", + carrier: null, + note: null, + addedAt: new Date().toISOString(), + }; + for (const d of ["2024", "2024-03", "2024-03-31"]) expect(TripSchema.parse({ ...trip, date: d }).date).toBe(d); + for (const d of ["2024-13", "2024-00", "2024-04-31"]) expect(TripSchema.parse({ ...trip, date: d }).date).toBeNull(); + // A file the app wrote before dates were checked (a CSV import let 2024-13-45 + // through) still loads whole. + const r = PostcardsFileSchema.safeParse({ + format: "postcards", + schemaVersion: 12, + exportedAt: new Date().toISOString(), + visits: [{ ...baseVisit(), date: "2024-13-45" }, { ...baseVisit(), date: "2024-01-02" }], + trips: [{ ...trip, date: "2024-02-30" }], + }); + expect(r.success).toBe(true); + expect(r.data?.visits.map((v) => v.date)).toEqual([null, "2024-01-02"]); + }); + + it("rejects a story dated on a day that does not exist", () => { + const story = { + storyId: "s", + place: { kind: "country", id: "FR", name: "France", countryId: "FR" }, + title: "t", + text: "", + addedAt: new Date().toISOString(), + }; + expect(StorySchema.safeParse({ ...story, date: "2024-02-29" }).success).toBe(true); + expect(StorySchema.safeParse({ ...story, date: "2024-13-45" }).success).toBe(false); + expect(StorySchema.safeParse({ ...story, date: "2024-02-30" }).success).toBe(false); + }); + it("can generate a JSON Schema for external tools (interoperability)", () => { // Zod 4 ships a native JSON-Schema exporter. Our schema has sanitizing // transforms, so describe the *input* shape and allow unrepresentable nodes. From d22fe6623d14deb61625e1e7f82ecf7232454bdc Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:00:46 +0900 Subject: [PATCH 012/102] Places: offer on a world browse only the filters it applies The Cities, Monuments and Airports browse list the gazetteer with your status laid over it, and ignored the date, folder, sort and favourite, photo and note filters the panel still offered there, which the badge and the chips counted as active. Those dimensions describe a saved record, so applying them would turn the world browse into your own list and leave Not visited always empty. The panel now leaves them out on a browse kind, and neither the badge nor the chips count them; your saved places keep every dimension. --- .../src/features/visits/PlacesScreen.tsx | 22 ++++- apps/postcards/src/ui/FilterPanel.tsx | 88 ++++++++++--------- .../tests/unit/placesScreen.spec.tsx | 40 +++++++++ 3 files changed, 103 insertions(+), 47 deletions(-) diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 2245a74..67b3569 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -32,6 +32,7 @@ import { browseList, type BrowseRow } from "./browseList"; import { useFilters, currentFilters, + type FilterState, type FilterStatus, type FilterMode, } from "../../lib/store/useFilters"; @@ -49,6 +50,15 @@ type Kind = "all" | "cities" | "monuments" | "airports" | "countries"; type Status = "all" | "visited" | "wishlist" | "favorites" | "notVisited"; type Collection = "moments" | "photos" | "passport"; +// Places owns status via its axis, and mode is the map's; neither is a filter here. +const LIST_EXCLUDE: (keyof FilterState)[] = ["status", "mode"]; +// A world browse (Cities / Monuments / Airports) lists reference places, not your +// records: date, folder and the favourite / photo / note toggles describe a saved +// record, and each kind keeps its own order. The panel leaves them out there and no +// chip counts them, rather than offering controls that do nothing. +const BROWSE_IGNORED: (keyof FilterState)[] = ["date", "folder", "sort", "favoritesOnly", "hasPhoto", "hasNote"]; +const BROWSE_EXCLUDE: (keyof FilterState)[] = [...LIST_EXCLUDE, ...BROWSE_IGNORED]; + const KINDS: readonly Kind[] = ["all", "cities", "monuments", "airports", "countries"]; const STATUSES: readonly Status[] = ["all", "visited", "wishlist", "favorites", "notVisited"]; @@ -693,9 +703,12 @@ export function PlacesScreen() { }, [visits, ref]); // The active dimensions Places actually acts on (status + map mode are excluded — - // status is the axis, mode is map-only). Drives the Filter button's badge. + // status is the axis, mode is map-only — and so is what a world browse ignores). + // Drives the Filter button's badge. + const isBrowseKind = kind === "cities" || kind === "monuments" || kind === "airports"; + const summaryExclude = isBrowseKind ? BROWSE_EXCLUDE : LIST_EXCLUDE; const placesFilterChips = useMemo( - () => activeChips(currentFilters(filters), t, ref).filter((c) => c.field !== "status" && c.field !== "mode"), + () => activeChips(currentFilters(filters), t, ref).filter((c) => !summaryExclude.includes(c.field)), // eslint-disable-next-line react-hooks/exhaustive-deps [ filters.date, @@ -708,6 +721,7 @@ export function PlacesScreen() { filters.hasNote, filters.continent, filters.country, + summaryExclude, ref, t, ], @@ -770,7 +784,6 @@ export function PlacesScreen() { ? t("places.collection.photos") : t("places.title"); - const isBrowseKind = kind === "cities" || kind === "monuments" || kind === "airports"; // The search box (and its filter row) belong to the browse; countries has its // own inline search, collections have none, and an empty personal list / the // "pick a kind" hint have nothing to filter. @@ -947,7 +960,7 @@ export function PlacesScreen() { ))}
)} - + )} @@ -1153,6 +1166,7 @@ export function PlacesScreen() { showStatus={false} showGrowth continents={continentOptions} + hidden={isBrowseKind ? BROWSE_IGNORED : undefined} /> ); diff --git a/apps/postcards/src/ui/FilterPanel.tsx b/apps/postcards/src/ui/FilterPanel.tsx index 5f33385..35dc0a6 100644 --- a/apps/postcards/src/ui/FilterPanel.tsx +++ b/apps/postcards/src/ui/FilterPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useFilters, POP_CHOICES, + type FilterState, type FilterStatus, type SortOrder, } from "../lib/store/useFilters"; @@ -22,6 +23,7 @@ export function FilterPanel({ showStatus = true, showGrowth = false, continents = [], + hidden = [], }: { open: boolean; onClose: () => void; @@ -36,6 +38,8 @@ export function FilterPanel({ showGrowth?: boolean; /** Continents present in the user's data, for the continent picker ("" = all). */ continents?: string[]; + /** Dimensions the host's list cannot act on, left out of the panel. */ + hidden?: (keyof FilterState)[]; }) { const t = useT(); const f = useFilters(); @@ -91,6 +95,13 @@ export function FilterPanel({ const STATUS: FilterStatus[] = ["visited", "wishlist", "unvisited"]; const SORTS: SortOrder[] = ["pop", "az"]; + const growth = ( + [ + ["favoritesOnly", "♥", "filter.favoritesOnly"], + ["hasPhoto", "📷", "filter.hasPhoto"], + ["hasNote", "📝", "filter.hasNote"], + ] as const + ).filter(([k]) => !hidden.includes(k)); return (
@@ -186,7 +197,7 @@ export function FilterPanel({
{/* Date */} - {(years.list.length > 0 || years.undated) && ( + {!hidden.includes("date") && (years.list.length > 0 || years.undated) && (
{t("filter.date.title")}
@@ -235,7 +246,7 @@ export function FilterPanel({ )} {/* Folder / trip */} - {folders.length > 0 && ( + {!hidden.includes("folder") && folders.length > 0 && (