From ebf288f9b97688695caebdc31b59acbd149527f0 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:27:13 +0000 Subject: [PATCH] Start a first run with the detailed map and full city list switched on Both switches on the welcome page start on. Nothing reaches the network until Start is tapped with them still on, which is the opt-in; Skip and offline mode apply neither, and a later visit shows the current settings. --- apps/postcards/src/lib/store/useSettings.ts | 5 +- apps/postcards/src/ui/IntroScreen.tsx | 53 ++++++++++++++-- .../tests/unit/introDefaults.spec.tsx | 63 +++++++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 apps/postcards/tests/unit/introDefaults.spec.tsx diff --git a/apps/postcards/src/lib/store/useSettings.ts b/apps/postcards/src/lib/store/useSettings.ts index 9abb620..0178dff 100644 --- a/apps/postcards/src/lib/store/useSettings.ts +++ b/apps/postcards/src/lib/store/useSettings.ts @@ -139,8 +139,9 @@ function loadAutoGuides(): boolean { // On first run the map is the bundled offline vector basemap — zero outbound // requests. Streaming detailed tiles from OpenStreetMap (which discloses your IP // and the area you're looking at) waits for a deliberate opt-in: the one-tap -// "Show detailed map" consent on the map, or this toggle in Settings. Only "1" -// enables, so a fresh install never reaches the network for a tile. +// "Show detailed map" consent on the map, this toggle in Settings, or Start on the +// welcome page with "Detailed map" left on. Only "1" enables, so a fresh install +// never reaches the network for a tile before one of those. function loadOnlineMap(): boolean { return readLocal(ONLINE_MAP_KEY) === "1"; } diff --git a/apps/postcards/src/ui/IntroScreen.tsx b/apps/postcards/src/ui/IntroScreen.tsx index 911754a..97d03e4 100644 --- a/apps/postcards/src/ui/IntroScreen.tsx +++ b/apps/postcards/src/ui/IntroScreen.tsx @@ -37,10 +37,26 @@ function Switch({ ); } +/** The welcome page has been closed once (App writes this when it closes). */ +const INTRO_KEY = "postcards-intro-seen"; +function introSeen(): boolean { + try { + return localStorage.getItem(INTRO_KEY) != null; + } catch { + return true; + } +} + /** * First-run welcome — a full page (not a modal) with a live spinning earth, * one line about what Postcards is, and a switch per optional feature so a * newcomer can flip on what they want on the spot. Skippable; shown once. + * + * On a first run, "Detailed map" and "Full city list" start switched on, but + * nothing reaches the network until the newcomer taps Start with them still on: + * that tap, with both switches in view, is the opt-in (Constitution: data leaves + * the device only on explicit user action). Skip applies neither. Opened again + * later, the switches show and change the current settings directly. */ export function IntroScreen({ onClose }: { onClose: () => void }) { const t = useT(); @@ -54,6 +70,10 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { const setReduceMapWork = useSettings((s) => s.setReduceMapWork); const showToast = useToast((s) => s.show); const startRef = useRef(null); + // First run: the two network features wait for Start (see above). + const [firstRun] = useState(() => !introSeen()); + const [wantMap, setWantMap] = useState(true); + const [wantCities, setWantCities] = useState(true); // Detect a phone / slower device (touch + small screen, or touch + few CPU // cores) so we can recommend the smooth-map settings up front. A laptop/desktop @@ -103,6 +123,27 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { showToast(ok ? t("settings.cities.toast.done") : t("settings.cities.toast.failed")); } + // Offline mode makes no optional request, so nothing waits for Start there: + // flipping the city list on downloads it at once, as it always has. + const deferred = firstRun && !offlineMode; + const mapOn = deferred ? wantMap : onlineMap; + const citiesOn = cities !== "idle" || (deferred && wantCities); + function toggleMap(on: boolean) { + if (deferred) setWantMap(on); + else setOnlineMap(on); + } + function toggleCities(on: boolean) { + if (deferred) setWantCities(on); + else if (on) void getCities(); + } + function start() { + if (deferred) { + setOnlineMap(wantMap); + if (wantCities && cities === "idle") void getCities(); + } + onClose(); + } + return (
- + )} @@ -196,17 +237,17 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { /> - {/* Offline city search — flipping it on downloads the full gazetteer - (~17 MB); once saved it stays on. */} + {/* Offline city search — on, it downloads the full gazetteer (~17 MB), on a + first run once Start is tapped; once saved it stays on. */}
  • 🏙️ {t("intro.cities.title")} {t("intro.cities.desc")}
    void getCities()} + onChange={toggleCities} label={t("intro.cities.title")} />
  • @@ -219,7 +260,7 @@ export function IntroScreen({ onClose }: { onClose: () => void }) { className="intro-start" type="button" title={t("intro.start")} - onClick={onClose} + onClick={start} > {t("intro.start")} diff --git a/apps/postcards/tests/unit/introDefaults.spec.tsx b/apps/postcards/tests/unit/introDefaults.spec.tsx new file mode 100644 index 0000000..c694e75 --- /dev/null +++ b/apps/postcards/tests/unit/introDefaults.spec.tsx @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +const download = vi.fn(async () => true); +vi.mock("../../src/lib/reference/referenceData", async (orig) => ({ + ...(await orig()), + downloadFullCities: () => download(), + fullCitiesEnabled: () => false, +})); + +import { IntroScreen } from "../../src/ui/IntroScreen"; +import { useSettings } from "../../src/lib/store/useSettings"; + +beforeEach(() => { + localStorage.clear(); + download.mockClear(); + useSettings.getState().setOnlineMap(false); + useSettings.getState().setOfflineMode(false); +}); +afterEach(cleanup); + +const map = () => screen.getByRole("switch", { name: "Detailed map" }) as HTMLInputElement; +const cities = () => screen.getByRole("switch", { name: "Full city list" }) as HTMLInputElement; + +it("starts a first run with the detailed map and full city list switched on", () => { + render( {}} />); + expect(map().checked).toBe(true); + expect(cities().checked).toBe(true); + // Nothing reaches the network before Start. + expect(useSettings.getState().onlineMap).toBe(false); + expect(download).not.toHaveBeenCalled(); +}); + +it("turns both on when Start is tapped", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Get started" })); + expect(useSettings.getState().onlineMap).toBe(true); + expect(download).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalled(); +}); + +it("leaves off what was switched off, and Skip applies neither", () => { + render( {}} />); + fireEvent.click(cities()); + fireEvent.click(screen.getByRole("button", { name: "Get started" })); + expect(useSettings.getState().onlineMap).toBe(true); + expect(download).not.toHaveBeenCalled(); + cleanup(); + + useSettings.getState().setOnlineMap(false); + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(useSettings.getState().onlineMap).toBe(false); + expect(download).not.toHaveBeenCalled(); +}); + +it("shows the current settings once the welcome has been seen", () => { + localStorage.setItem("postcards-intro-seen", "1"); + render( {}} />); + expect(map().checked).toBe(false); + expect(cities().checked).toBe(false); +});