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/.github/workflows/tests.yml b/.github/workflows/tests.yml index e691685..bafc796 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,3 +36,10 @@ jobs: run: pnpm --filter postcards exec playwright install --with-deps chromium - name: E2E tests (incl. a11y gate) run: pnpm --filter postcards test:e2e + - name: Upload e2e traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: apps/postcards/test-results/ + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index f6f4c48..355100d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,11 +37,12 @@ observed working: a key added to `en.ts` must be added to `fr.ts` and `ko.ts` (and removing one means removing it from all three), or tsc fails. 3. `npx vitest run` — full unit suite green. -4. `npx playwright test` — e2e green. `smoke`/`photo`/`countryscope`/`import-csv` - can flake under full-suite CPU load; re-run the file in isolation to confirm, - CI retry absorbs it. -5. For any UI change, **screenshot and eyeball it** (Chromium is at - `/opt/pw-browsers/chromium-1194/chrome-linux/chrome`). +4. `npx playwright test` — e2e green. On a host whose network interfaces churn + (many docker containers, for one), Chromium aborts in-flight requests with + `net::ERR_NETWORK_CHANGED`, and a spec can find the app never booted (the + reload message or a blank page); re-run the file, and CI's retry absorbs it. +5. For any UI change, **screenshot and eyeball it** (Playwright's Chromium, + from `npx playwright install chromium` in `apps/postcards`). 6. Commit with a clear message. **Never** put the model identifier in commits, PRs, or code — chat only. 7. Deploy: push HEAD to the feature branch **and** fast-forward the deployed @@ -70,7 +71,14 @@ Cmd/Ctrl+Shift+R) before re-implementing. user action. Optional egress (map tiles, guides, photos) is opt-in and off by default; **Offline mode** is the master switch that forces zero egress. - The GitHub sync token stays on-device: never in exports, published sites, or - logs. The `connect-src` CSP in `index.html` is the backstop. + logs. The `connect-src` CSP in `index.html` keeps a script in the page from + sending it to any host outside its list; it does not stop a request to + `api.github.com`, which the list allows, carrying the token to a repository + of the script's choosing. The token's safety also depends on the app being + the only app served from its origin, since every page of an origin shares its + localStorage: `offware-apps.github.io` currently also serves another app, and + a custom domain or another dedicated origin removes that dependency. Hence + the fine-grained token, one repository, Contents read and write only. - Imports are validated + sanitized, never executed. One portable JSON file. - WCAG 2.1 AA, keyboard-first; every interactive control carries a `title` (plus `aria-label` when icon-only). diff --git a/README.md b/README.md index cc059b9..4c36c71 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ -> offware-apps.github.io/Postcards/ +-> Android: [download postcards.apk](https://github.com/offware-apps/Postcards/releases/download/android-latest/postcards.apk) (newest build; open it on the phone and allow installing unknown apps) +
# Postcards 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/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index c023e50..fffe014 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 2127973..f2ba7c8 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index b441f37..7749d86 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/apps/postcards/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 72905b8..3d91dfd 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 8ed0605..5fb6d25 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 9502e47..364e970 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/apps/postcards/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 4d1e077..64412f4 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index df0f158..865df30 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 853db04..ef929ff 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/apps/postcards/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 6cdf97c..2cbb801 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 2960cbb..2a36a99 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8e3093a..3eef088 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/apps/postcards/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 46de6e2..fef63e0 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index d2ea9ab..2ce4642 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index a40d73e..60488e2 100644 Binary files a/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/apps/postcards/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/postcards/android/app/src/main/res/values/ic_launcher_background.xml b/apps/postcards/android/app/src/main/res/values/ic_launcher_background.xml index c5d5899..52f57da 100644 --- a/apps/postcards/android/app/src/main/res/values/ic_launcher_background.xml +++ b/apps/postcards/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #FFFFFF + #4338CA \ No newline at end of file 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/index.html b/apps/postcards/index.html index c431e24..bf5ea54 100644 --- a/apps/postcards/index.html +++ b/apps/postcards/index.html @@ -4,22 +4,29 @@ - + "; +/** The footer of root indexes written before the marker existed. */ +const LEGACY_ROOT_FOOTER = "Published with Postcards — a private, local-first travel journal."; +/** The first line of the README Postcards ships in every travel folder. */ +const TRAVEL_README_HEAD = HOSTING_README.split("\n", 1)[0]!; +/** Folder names of passphrase-locked travels: random, so they name nothing. */ +const LOCKED_SLUG = /^locked-[0-9a-f]{12}$/; +const LOCKED_KEY = "postcards-publish-locked-slugs"; + +/** A URL-safe subdirectory name for one travel, e.g. "Japan 2024" → "japan-2024", + * "東京 2024" → "東京-2024". Letters and digits of every script are kept (Latin + * diacritics dropped), so a Korean, Japanese or Cyrillic name gets its own folder + * rather than the shared fallback "journey" overwriting an earlier publish. */ +export function slugify(name: string): string { + const s = name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") // strip Latin combining diacritics + .normalize("NFC") // recompose Hangul syllables and kana voicing marks + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, ""); + return [...s].slice(0, 60).join("").replace(/-+$/, "") || "journey"; +} + +/** The folder of a passphrase-locked travel: random, so its public URL says + * nothing about the journey, and remembered on this device per repo and travel + * name so a re-publish updates the same folder instead of adding another. */ +export function lockedSlug(repo: string, name: string): string { + const key = `${repo}\n${name}`; + let saved: Record = {}; + try { + saved = JSON.parse(localStorage.getItem(LOCKED_KEY) || "{}") as Record; + } catch { + /* private mode or a damaged entry: start afresh */ + } + const known = saved[key]; + if (known && LOCKED_SLUG.test(known)) return known; + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(6)); + const slug = `locked-${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`; + try { + localStorage.setItem(LOCKED_KEY, JSON.stringify({ ...saved, [key]: slug })); + } catch { + /* not remembered: the next publish gets a new folder */ + } + return slug; +} + +/** Whether owner/repo is the device-sync repository (case-insensitive, as GitHub is). */ +export function isSyncRepo(owner: string, repo: string): boolean { + const sync = readRemoteConfig(); + return ( + !!sync.owner && + !!sync.repo && + sync.owner.toLowerCase() === owner.toLowerCase() && + sync.repo.toLowerCase() === repo.toLowerCase() + ); +} + +/** A minimal, inert root landing page listing every published travel folder, so + * the repo root isn't a 404 and visitors can browse between journeys. */ +function buildRootIndex(siteTitle: string, folders: string[]): string { + const esc = (x: string) => + x.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const items = folders + .map((f) => `
  • ${esc(f.replace(/-/g, " "))}
  • `) + .join("\n "); + return ` +${ROOT_MARKER} + + +${esc(siteTitle)} + + +

    ${esc(siteTitle)}

    +
      + ${items} +
    +

    Published with Postcards — a private, local-first travel journal.

    + +`; +} + +/** + * Push one travel into its folder, then refresh the root index (best-effort). + * A locked travel's commit message and the index never carry its name. The root + * index.html is rewritten only when Postcards wrote it (a user's own homepage is + * left alone), and lists only folders holding a Postcards travel README, never + * the repo's other directories or a locked travel. + */ +export async function publishTravel( + target: GitHubTarget, + opts: { html: string; name: string; slug: string; locked: boolean; siteTitle: string }, +): Promise { + await target.putFiles( + [ + { path: `${opts.slug}/index.html`, content: opts.html }, + // Ship the host-facing README beside each travel (FR-015). + { path: `${opts.slug}/README.md`, content: HOSTING_README }, + ], + opts.locked ? "Publish a travel via Postcards" : `Publish "${opts.name}" via Postcards`, + ); + try { + const current = await target.getFile("index.html"); + if (current && !current.content.includes(ROOT_MARKER) && !current.content.includes(LEGACY_ROOT_FOOTER)) return; + const folders: string[] = []; + for (const e of await target.listDir("")) { + if (e.type !== "dir" || e.name.startsWith(".") || LOCKED_SLUG.test(e.name)) continue; + const readme = await target.getFile(`${e.name}/README.md`); + if (readme?.content.startsWith(TRAVEL_README_HEAD)) folders.push(e.name); + } + if (!opts.locked && !folders.includes(opts.slug)) folders.push(opts.slug); + if (!current && folders.length === 0) return; + folders.sort((a, b) => a.localeCompare(b)); + await target.putFiles( + [{ path: "index.html", content: buildRootIndex(opts.siteTitle, folders) }], + "Update travels index via Postcards", + ); + } catch { + /* listing/root-index is a nicety; the travel itself already published */ + } +} diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 2132edc..620a341 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -124,6 +124,8 @@ interface IndexedHeritage extends HeritageSite { // Community-pack places, indexed for search, merged into every ReferenceData // instance's city set. Module-level so they survive a gazetteer swap / reinit. let packCities: IndexedCity[] = []; +// Other ids a pack place also answers to (alias -> its id); see packs/store. +let packAliases = new Map(); class ReferenceDataImpl implements ReferenceData { readonly countries: Country[]; @@ -186,6 +188,10 @@ class ReferenceDataImpl implements ReferenceData { this.cities = packCities.length ? this.baseCities.concat(packCities) : this.baseCities; this.cityIndex.clear(); for (const c of this.cities) this.cityIndex.set(c.id, c); + for (const [alias, id] of packAliases) { + const c = this.cityIndex.get(id); + if (c && !this.cityIndex.has(alias)) this.cityIndex.set(alias, c); + } this.citiesByCountry.clear(); // per-country slices rebuild lazily from the new set } @@ -523,9 +529,10 @@ async function upgradeToFullGazetteer(impl: ReferenceDataImpl): Promise { * mappable city set. Called by the packs store at startup and whenever a pack is * added or removed. Fires the gazetteer event so screens holding memoized city * snapshots refresh. Pack ids are namespaced (pack::), so they never - * collide with GeoNames ids. + * collide with GeoNames ids; `aliases` are further ids cityById resolves to them. */ -export function setPackPlaces(places: City[]): void { +export function setPackPlaces(places: City[], aliases = new Map()): void { + packAliases = aliases; packCities = places .map((c) => ({ ...c, search: normalize(c.name) })) .sort((a, b) => (b.population ?? 0) - (a.population ?? 0)); @@ -563,21 +570,24 @@ async function applyFullGazetteer(impl: ReferenceDataImpl): Promise { /** * User-triggered download of the full world city list (~17 MB) — the "rest" that - * isn't bundled with the app. Records the opt-in (so future launches re-load it - * from cache), fetches + swaps the set in, and resolves true on success. A no-op - * that returns true if the full set is already loaded. Never throws. + * isn't bundled with the app. Fetches + swaps the set in, resolves true on + * success, and only then records it (so future launches re-load it from cache + * and Settings shows it downloaded). A no-op that returns true if the full set + * is already loaded. Never throws. */ export async function downloadFullCities(): Promise { - try { - localStorage.setItem(FULL_CITIES_KEY, "1"); - } catch { - /* private mode: the download still works this session, just isn't remembered */ - } const impl = instance as ReferenceDataImpl | null; if (!impl) return false; // Already the full set? (core is 10k; the full set is ~135k.) - if (impl.allCities().length >= 100_000) return true; - return applyFullGazetteer(impl); + const ok = impl.allCities().length >= 100_000 || (await applyFullGazetteer(impl)); + if (ok) { + try { + localStorage.setItem(FULL_CITIES_KEY, "1"); + } catch { + /* private mode: the download still works this session, just isn't remembered */ + } + } + return ok; } // Bumped when the full gazetteer replaces the core set, so React consumers can diff --git a/apps/postcards/src/lib/schema/helpers.ts b/apps/postcards/src/lib/schema/helpers.ts index fcac3d7..2c5f618 100644 --- a/apps/postcards/src/lib/schema/helpers.ts +++ b/apps/postcards/src/lib/schema/helpers.ts @@ -115,3 +115,31 @@ 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); +} + +/** The stamp of a record's deletion: now, or just after the record's own stamp + * when that is later (a device whose clock runs fast wrote it), so the tombstone + * always sorts after the version it deletes and the merge keeps it deleted. */ +export function stampDeletion(r: { updatedAt?: string; addedAt: string } | undefined): string { + const own = r ? Date.parse(r.updatedAt ?? r.addedAt) + 1 : NaN; + return new Date(own > Date.now() ? own : Date.now()).toISOString(); +} diff --git a/apps/postcards/src/lib/schema/models.ts b/apps/postcards/src/lib/schema/models.ts index 3a605c6..3c0178c 100644 --- a/apps/postcards/src/lib/schema/models.ts +++ b/apps/postcards/src/lib/schema/models.ts @@ -7,7 +7,9 @@ import { MAX_PLACES_PER_STORY, MAX_TAGS_PER_STORY, MAX_TAG_LEN, + isCalendarDate, } from "./helpers"; +import { isDecodableDataUrl } from "../image/photoBlobs"; // Canonical, versioned schema for the portable data file. // Single source of truth: these Zod models generate TS types AND the published @@ -45,8 +47,8 @@ export const PlaceRefSchema = z .min(1) .max(200) .transform((s) => sanitizeText(s, 200)) - // min(1) runs on the INPUT; a name of only formula-prefix chars ("===") - // sanitizes to "" and would poison the file — reject it clearly instead. + // min(1) runs on the INPUT; a name of only invisible characters (a lone + // U+200B) sanitizes to "" and would poison the file — reject it clearly instead. .refine((s) => s.length > 0, { message: "Name is empty once sanitized" }), countryId: isoCountryId, // Coordinates carried on the record itself — only used by kind "custom" @@ -77,7 +79,10 @@ const photoDataUrl = z .refine( (s) => /^data:image\/(png|jpe?g|webp|gif|avif);/i.test(s), "photo must be an inline raster image data URL", - ); + ) + // A base64 payload that does not decode renders as nothing and breaks the photo + // store and the archive, so the file is refused here with the normal error. + .refine(isDecodableDataUrl, "photo data does not decode"); /** * One photo in a place's gallery: the inline image + an optional short caption @@ -108,6 +113,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; + /** One personal tag on a postcard (a mood, a weather note, a free label) — a * bounded, sanitized, non-empty string. Personal data, never reference data * (Constitution I): a tag invents no world fact. Mood/weather are just preset @@ -138,7 +150,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 @@ -226,7 +238,8 @@ export const TripSchema = z z .string() .regex(/^\d{4}(-\d{2}(-\d{2})?)?$/) - .nullable(), + .nullable() + .transform(calendarDateOrNull), ) .max(200) .optional(), @@ -239,7 +252,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 }), @@ -274,7 +287,10 @@ export const StorySchema = z */ extraPlaces: z.array(PlaceRefSchema).max(MAX_PLACES_PER_STORY - 1).optional(), /** The day the postcard is about — required (the start day of any range). */ - 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" }), /** * Optional range END day. Additive & optional and NEVER injected on parse (no * default, no null transform) so v1–v12 files round-trip byte-identically: @@ -283,6 +299,7 @@ export const StorySchema = z endDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine(isCalendarDate, { message: "endDate must be a real calendar day" }) .optional(), // 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 diff --git a/apps/postcards/src/lib/schema/sanitize.ts b/apps/postcards/src/lib/schema/sanitize.ts index 1b8c5a1..fb6cd6e 100644 --- a/apps/postcards/src/lib/schema/sanitize.ts +++ b/apps/postcards/src/lib/schema/sanitize.ts @@ -1,14 +1,15 @@ // Inert-data helpers (Constitution VI: data is parsed, never executed). -// These neutralize content that could be dangerous when a data file is later -// opened in another tool (e.g. a spreadsheet) or rendered. - -/** Characters that trigger formula evaluation in spreadsheet software. */ -const FORMULA_PREFIXES = new Set(["=", "+", "-", "@"]); +// These strip characters that spoof how text reads once rendered. Spreadsheet +// formula prefixes are neutralized only where a spreadsheet opens the text, in +// the CSV writer (exportCsv.ts): a leading "-" or "=" is plain text in the JSON +// file and in the app, and stripping it here rewrote user text on every parse. /** * Remove characters that are invisible or can spoof text direction: * - C0 AND C1 control characters (0x00–0x1F, 0x7F, 0x80–0x9F; except tab 9 / LF 10), - * - zero-width characters (U+200B–200D, U+2060 word joiner, U+FEFF), + * - zero-width characters (U+200B space, U+2060 word joiner, U+FEFF), but not + * the joiners U+200C/U+200D, which emoji sequences and Persian or Indic + * spelling need, * - directional marks (U+200E LRM, U+200F RLM, U+061C ALM), * - Unicode bidirectional overrides/isolates (U+202A–202E, U+2066–2069) * — the "Trojan Source" class of visual-spoofing attacks. @@ -23,8 +24,7 @@ function stripControlChars(input: string): string { (code >= 0 && code <= 31 && code !== 9 && code !== 10) || code === 127 || (code >= 0x80 && code <= 0x9f); - const isZeroWidth = - code === 0x200b || code === 0x200c || code === 0x200d || code === 0x2060 || code === 0xfeff; + const isZeroWidth = code === 0x200b || code === 0x2060 || code === 0xfeff; const isDirMark = code === 0x200e || code === 0x200f || code === 0x061c; // LRM / RLM / ALM const isBidi = (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069); if (!isC0C1 && !isZeroWidth && !isDirMark && !isBidi) out += ch; @@ -35,15 +35,11 @@ function stripControlChars(input: string): string { /** * Sanitize a free-text string from (potentially untrusted) data: * - normalize newlines and remove control characters, - * - neutralize leading formula/command characters, * - collapse to a bounded length. * Returns plain, inert text. Never evaluates anything. */ export function sanitizeText(input: string, maxLength = 2000): string { let out = stripControlChars(input.replace(/\r\n?/g, "\n")).trim(); - while (out.length > 0 && (FORMULA_PREFIXES.has(out[0]!) || out[0] === "\t")) { - out = out.slice(1).trimStart(); - } if (out.length > maxLength) out = out.slice(0, maxLength); return out; } diff --git a/apps/postcards/src/lib/serviceWorker.ts b/apps/postcards/src/lib/serviceWorker.ts new file mode 100644 index 0000000..c383858 --- /dev/null +++ b/apps/postcards/src/lib/serviceWorker.ts @@ -0,0 +1,61 @@ +import { useUpdate } from "./store/useUpdate"; +import { useSettings } from "./store/useSettings"; + +/** How often an always-open tab checks for a fresh deploy. */ +export const UPDATE_POLL_MS = 30 * 60 * 1000; + +/** + * Keep open tabs off a stale cached build. We register the generated service + * worker ourselves (no workbox-window dependency): when a NEW build installs and + * waits, we surface a "new version — reload" banner (see UpdateBanner) rather + * than swapping code under the user. Tapping Reload posts SKIP_WAITING (the SW + * listens for it), which activates the new worker; `controllerchange` then + * reloads the page once into the fresh build. A 30-min poll lets an always-open + * tab discover a deploy without a manual reload. The very first install (no + * existing controller) never prompts — it just primes the offline cache, and + * takes control of the page (clientsClaim) without reloading it. + */ +export function registerServiceWorker(): void { + let reloading = false; + let controlled = !!navigator.serviceWorker.controller; + navigator.serviceWorker.addEventListener("controllerchange", () => { + // The first install claiming an uncontrolled page is not a new build. + if (!controlled) { + controlled = true; + return; + } + if (reloading) return; + reloading = true; + window.location.reload(); + }); + const offerIfWaiting = (worker: ServiceWorker | null) => { + if (worker && worker.state === "installed" && navigator.serviceWorker.controller) { + useUpdate.getState().announce(() => worker.postMessage({ type: "SKIP_WAITING" })); + } + }; + navigator.serviceWorker + .register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL }) + .then((reg) => { + offerIfWaiting(reg.waiting); + reg.addEventListener("updatefound", () => { + const nw = reg.installing; + nw?.addEventListener("statechange", () => offerIfWaiting(nw)); + }); + // Poll for a fresh deploy — but NOT in Offline mode, where the app makes + // zero app-initiated network requests (the update check is optional egress). + // A build dismissed earlier is still waiting and fires no new event, so each + // poll offers it again (a newer one found by the check announces itself). + setInterval(() => { + if (useSettings.getState().offlineMode) return; + void reg + .update() + .catch(() => { + /* no network right now: whatever is already waiting still applies */ + }) + .then(() => offerIfWaiting(reg.waiting)); + }, UPDATE_POLL_MS); + }) + .catch(() => { + /* registration unavailable (insecure context / unsupported): app still works */ + }); +} diff --git a/apps/postcards/src/lib/store/portable.ts b/apps/postcards/src/lib/store/portable.ts index 9c00777..73385d0 100644 --- a/apps/postcards/src/lib/store/portable.ts +++ b/apps/postcards/src/lib/store/portable.ts @@ -1,6 +1,11 @@ +import { getAllVisits, hasIndexedDB, onOtherTabWrite, readSettled } from "../db/visitsDb"; +import { getAllTrips } from "../db/tripsDb"; +import { getAllStories } from "../db/storiesDb"; +import { backfillUpdatedAt, normalizeVisitPhotos } from "../schema/helpers"; +import { markApplyingSync } from "../sync/applyMark"; import { useVisits } from "./useVisits"; import { useTrips } from "./useTrips"; -import { useStories } from "./useStories"; +import { sortStories, useStories } from "./useStories"; // The three stores the portable file carries, loaded and awaited together. @@ -16,3 +21,47 @@ export function usePortableLoaded(): boolean { const stories = useStories((s) => s.loaded); return visits && trips && stories; } + +/** Replace the three stores with what the database holds now. */ +async function reloadPortable(): Promise { + const stores = [useVisits, useTrips, useStories]; + // Before the startup read settles, that read is the one to wait for. + if (!hasIndexedDB() || !stores.every((s) => s.getState().loaded)) return; + await readSettled( + () => Promise.all([getAllVisits(), getAllTrips(), getAllStories()]), + ([visits, trips, stories]) => + // Another tab's write, not an edit here: auto-sync must not push it again. + markApplyingSync(() => { + useVisits.setState({ visits: visits.map(normalizeVisitPhotos).map(backfillUpdatedAt) }); + useTrips.setState({ trips: trips.map(backfillUpdatedAt) }); + useStories.setState({ stories: sortStories(stories.map(backfillUpdatedAt)) }); + }), + ); +} + +/** + * Keep the stores in step with the other open tabs: after each write one of them + * makes, re-read the database. Writes arriving during a re-read take one more. + * Returns the unsubscribe. + */ +export function followOtherTabs(): () => void { + let reading = false; + let again = false; + const reload = (): void => { + if (reading) { + again = true; + return; + } + reading = true; + void reloadPortable() + .catch(() => {}) + .finally(() => { + reading = false; + if (again) { + again = false; + reload(); + } + }); + }; + return onOtherTabWrite(reload); +} diff --git a/apps/postcards/src/lib/store/useFilters.ts b/apps/postcards/src/lib/store/useFilters.ts index 948cc8a..711266a 100644 --- a/apps/postcards/src/lib/store/useFilters.ts +++ b/apps/postcards/src/lib/store/useFilters.ts @@ -184,29 +184,6 @@ function persist(state: FilterState): void { ); } -/** True iff every dimension is at its default (⇒ no active filters, empty summary). */ -export function isDefault(s: FilterState): boolean { - return ( - s.status.length === 0 && - s.minPop === 0 && - s.date.mode === "all" && - s.folder === "" && - s.sort === "pop" && - s.mode === "all" && - s.category === "" && - s.country === "" && - !s.favoritesOnly && - !s.hasPhoto && - !s.hasNote && - s.continent === "" - ); -} - -/** A copy of `s` with exactly `field` reset to its default (for a chip's ✕). */ -export function withFieldCleared(s: FilterState, field: keyof FilterState): FilterState { - return { ...s, [field]: DEFAULT_FILTERS[field] } as FilterState; -} - interface FilterStore extends FilterState { set: (partial: Partial) => void; clearField: (field: keyof FilterState) => void; diff --git a/apps/postcards/src/lib/store/useStories.ts b/apps/postcards/src/lib/store/useStories.ts index 1270e87..b49fe58 100644 --- a/apps/postcards/src/lib/store/useStories.ts +++ b/apps/postcards/src/lib/store/useStories.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { backfillUpdatedAt, stampNow } from "../schema/helpers"; +import { backfillUpdatedAt, stampDeletion, stampNow } from "../schema/helpers"; import type { Photo, PlaceRef, Story } from "../schema/models"; import * as db from "../db/storiesDb"; import * as visitsDb from "../db/visitsDb"; @@ -47,7 +47,8 @@ interface StoriesState { >, ) => Promise; removeStory: (storyId: string) => Promise; - setAll: (stories: Story[]) => Promise; + /** Put ONE story back (the undo of a delete or an edit): upsert by storyId. */ + restoreStory: (story: Story) => Promise; } export const useStories = create((set, get) => ({ @@ -55,7 +56,9 @@ export const useStories = create((set, get) => ({ loaded: false, async load() { // Backfill `updatedAt` from `addedAt` for stories made before sync existed. - const stories = sortStories((await db.getAllStories()).map(backfillUpdatedAt)); + const stories = sortStories( + (await visitsDb.loadOrEmpty(db.getAllStories)).map(backfillUpdatedAt), + ); set({ stories, loaded: true }); }, async addStory({ place = null, extraPlaces, date, endDate, title, text, photos = [], folder = null, tags, tripId }) { @@ -113,15 +116,20 @@ export const useStories = create((set, get) => ({ await db.putStory(updated); }, async removeStory(storyId) { + const gone = get().stories.find((s) => s.storyId === storyId); set({ stories: get().stories.filter((s) => s.storyId !== storyId) }); await db.deleteStory(storyId); // Tombstone the deletion so it propagates on sync (spec 013, FR-009). - await visitsDb.putTombstone("story", storyId, stampNow()); + await visitsDb.putTombstone("story", storyId, stampDeletion(gone)); }, - async setAll(stories) { - // Bulk load: backfill `updatedAt` without stamping "now" (keep real ages). - const sorted = sortStories(stories.map(backfillUpdatedAt)); - set({ stories: sorted }); - await db.replaceAllStories(sorted); + async restoreStory(story) { + // Bump `updatedAt` so the restored story wins on the next merge, over its own + // tombstone or over the undone edit a sync already pushed, and clear that + // tombstone so the restore is clean (mirrors useVisits.restoreVisit). + const restored: Story = { ...story, updatedAt: stampNow() }; + const rest = get().stories.filter((s) => s.storyId !== restored.storyId); + set({ stories: sortStories([...rest, restored]) }); + await db.putStory(restored); + await visitsDb.deleteTombstone("story", restored.storyId); }, })); diff --git a/apps/postcards/src/lib/store/useTrips.ts b/apps/postcards/src/lib/store/useTrips.ts index fe7f1a7..a4169a3 100644 --- a/apps/postcards/src/lib/store/useTrips.ts +++ b/apps/postcards/src/lib/store/useTrips.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { backfillUpdatedAt, stampNow } from "../schema/helpers"; +import { backfillUpdatedAt, stampDeletion, stampNow } from "../schema/helpers"; import type { PlaceRef, TravelMode, Trip } from "../schema/models"; import * as db from "../db/tripsDb"; import * as visitsDb from "../db/visitsDb"; @@ -37,7 +37,8 @@ interface TripsState { >, ) => Promise; removeTrip: (tripId: string) => Promise; - setAll: (trips: Trip[]) => Promise; + /** Put ONE trip back (the undo of a delete or an edit): upsert by tripId. */ + restoreTrip: (trip: Trip) => Promise; } export const useTrips = create((set, get) => ({ @@ -45,7 +46,7 @@ export const useTrips = create((set, get) => ({ loaded: false, async load() { // Backfill `updatedAt` from `addedAt` for trips made before sync existed. - const trips = (await db.getAllTrips()).map(backfillUpdatedAt); + const trips = (await visitsDb.loadOrEmpty(db.getAllTrips)).map(backfillUpdatedAt); set({ trips, loaded: true }); }, async addTrip({ @@ -109,15 +110,24 @@ export const useTrips = create((set, get) => ({ await db.putTrip(updated); }, async removeTrip(tripId) { + const gone = get().trips.find((t) => t.tripId === tripId); set({ trips: get().trips.filter((t) => t.tripId !== tripId) }); await db.deleteTrip(tripId); // Tombstone the deletion so it propagates on sync (spec 013, FR-009). - await visitsDb.putTombstone("trip", tripId, stampNow()); + await visitsDb.putTombstone("trip", tripId, stampDeletion(gone)); }, - async setAll(trips) { - // Bulk load: backfill `updatedAt` without stamping "now" (keep real ages). - const backfilled = trips.map(backfillUpdatedAt); - set({ trips: backfilled }); - await db.replaceAllTrips(backfilled); + async restoreTrip(trip) { + // Bump `updatedAt` so the restored trip wins on the next merge, over its own + // tombstone or over the undone edit a sync already pushed, and clear that + // tombstone so the restore is clean (mirrors useVisits.restoreVisit). + const restored: Trip = { ...trip, updatedAt: stampNow() }; + const exists = get().trips.some((t) => t.tripId === restored.tripId); + set({ + trips: exists + ? get().trips.map((t) => (t.tripId === restored.tripId ? restored : t)) + : [...get().trips, restored], + }); + await db.putTrip(restored); + await visitsDb.deleteTombstone("trip", restored.tripId); }, })); diff --git a/apps/postcards/src/lib/store/useUi.ts b/apps/postcards/src/lib/store/useUi.ts index 8a02c40..8f83cfd 100644 --- a/apps/postcards/src/lib/store/useUi.ts +++ b/apps/postcards/src/lib/store/useUi.ts @@ -5,12 +5,14 @@ export type Tab = "map" | "stats" | "places" | "trips" | "journal" | "settings"; // Passport and Moments live INSIDE Places now (fewer top-level buttons) — they // are views of the same screen, reachable via openPlaces. export type PlacesView = - | "visited" | "favorites" | "wishlist" | "countries" | "cities" | "monuments" + // The cities / monuments you've visited — what the stats tiles count. + | "visitedCities" + | "visitedMonuments" | "airports" | "stations" | "moments" diff --git a/apps/postcards/src/lib/store/useUpdate.ts b/apps/postcards/src/lib/store/useUpdate.ts index 981e698..2dcb910 100644 --- a/apps/postcards/src/lib/store/useUpdate.ts +++ b/apps/postcards/src/lib/store/useUpdate.ts @@ -2,10 +2,10 @@ import { create } from "zustand"; /** * Bridges the service-worker "a new build is waiting" event into the React tree. - * The registration itself lives in `main.tsx` (the entry, outside the test - * graph) so unit tests never import Vite's `virtual:pwa-register`. When a new - * version is ready, `main.tsx` flags it here and hands over the `apply` function - * that activates the waiting worker and reloads to the fresh build. + * The registration itself lives in `lib/serviceWorker.ts` (plain + * navigator.serviceWorker, no Vite `virtual:pwa-register`). When a new version is + * ready, it flags it here and hands over the `apply` function that activates the + * waiting worker and reloads to the fresh build. */ interface UpdateState { /** True once a newer deployed build is installed and waiting to take over. */ diff --git a/apps/postcards/src/lib/store/useVisits.ts b/apps/postcards/src/lib/store/useVisits.ts index 957cd69..6329860 100644 --- a/apps/postcards/src/lib/store/useVisits.ts +++ b/apps/postcards/src/lib/store/useVisits.ts @@ -1,5 +1,12 @@ import { create } from "zustand"; -import { backfillUpdatedAt, MAX_PHOTOS_PER_VISIT, normalizeVisitPhotos, placeKey, stampNow } from "../schema/helpers"; +import { + backfillUpdatedAt, + MAX_PHOTOS_PER_VISIT, + normalizeVisitPhotos, + placeKey, + stampDeletion, + stampNow, +} from "../schema/helpers"; import type { Photo, PlaceRef, Visit } from "../schema/models"; import { sanitizeText } from "../schema/sanitize"; import * as db from "../db/visitsDb"; @@ -46,7 +53,7 @@ export function visitIndex(list: Visit[]): Map { } /** Today as a local YYYY-MM-DD — the default "visited on" for a new visit. */ -function todayISO(): string { +export function todayISO(): string { const d = new Date(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); @@ -85,8 +92,8 @@ interface VisitsState { visitId: string, details: { date?: string | null; note?: string | null; folder?: string | null }, ) => Promise; - /** Put ONE visit back (single-record undo): upsert by visitId, one write — - * setAll would clear and rewrite the entire visits table. */ + /** Put ONE visit back (single-record undo): upsert by visitId, one write, + * never a rewrite of the entire visits table. */ restoreVisit: (visit: Visit) => Promise; /** Merge imported places into the existing visits, upserting by (kind,id): * a NON-destructive add (trips, stories, and any place not in the file are @@ -95,7 +102,6 @@ interface VisitsState { mergeVisits: ( incoming: { place: PlaceRef; status: Visit["status"]; favorite?: boolean; date?: string | null }[], ) => Promise<{ added: number; updated: number }>; - setAll: (visits: Visit[]) => Promise; } export const useVisits = create((set, get) => ({ @@ -104,7 +110,9 @@ export const useVisits = create((set, get) => ({ async load() { // Migrate any legacy single-photo records into the `photos` gallery, and // backfill `updatedAt` from `addedAt` for records made before sync existed. - const dbVisits = (await db.getAllVisits()).map(normalizeVisitPhotos).map(backfillUpdatedAt); + const dbVisits = (await db.loadOrEmpty(db.getAllVisits)) + .map(normalizeVisitPhotos) + .map(backfillUpdatedAt); // Don't clobber optimistic writes that landed DURING this async read: marking a // place and immediately opening a list would otherwise blank it (the snapshot // predated the in-flight putVisit). Merge by id, newest `updatedAt` winning — @@ -145,11 +153,12 @@ export const useVisits = create((set, get) => ({ return visit; }, async removeVisit(visitId) { + const gone = get().visits.find((v) => v.visitId === visitId); set({ visits: get().visits.filter((v) => v.visitId !== visitId) }); await db.deleteVisit(visitId); // Record a tombstone so the deletion propagates on sync instead of the record // being re-added by a device that still holds it (spec 013, FR-009). - await db.putTombstone("visit", visitId, stampNow()); + await db.putTombstone("visit", visitId, stampDeletion(gone)); }, async toggleVisit(place) { const existing = findByPlace(get().visits, place); @@ -248,24 +257,27 @@ export const useVisits = create((set, get) => ({ }, async mergeVisits(incoming) { const byKey = new Map(get().visits.map((v) => [placeKey(v.place), v])); + const touched: Visit[] = []; let added = 0; let updated = 0; for (const item of incoming) { const key = placeKey(item.place); const existing = byKey.get(key); if (existing) { - byKey.set(key, { + const next: Visit = { ...existing, place: item.place, // refresh coords/name if the import carries better status: item.status, favorite: item.favorite ?? existing.favorite, date: item.date ?? existing.date, updatedAt: stampNow(), - }); + }; + byKey.set(key, next); + touched.push(next); updated++; } else { const at = new Date().toISOString(); - byKey.set(key, { + const next: Visit = { visitId: uuid(), place: item.place, status: item.status, @@ -275,21 +287,16 @@ export const useVisits = create((set, get) => ({ photos: [], addedAt: at, updatedAt: at, - }); + }; + byKey.set(key, next); + touched.push(next); added++; } } - const merged = [...byKey.values()]; - set({ visits: merged }); - await db.replaceAllVisits(merged); + set({ visits: [...byKey.values()] }); + // Only the places the file touched: a rewrite of the whole table from memory + // would drop what another tab wrote since this one last read it. + await db.putVisits(touched); return { added, updated }; }, - async setAll(visits) { - // Bulk load (restore/import): normalize photos and backfill `updatedAt` from - // `addedAt` for records that predate the field; never stamp "now" here, so an - // imported old record keeps its real age for newest-wins. - const normalized = visits.map(normalizeVisitPhotos).map(backfillUpdatedAt); - set({ visits: normalized }); - await db.replaceAllVisits(normalized); - }, })); diff --git a/apps/postcards/src/lib/sync/engine.ts b/apps/postcards/src/lib/sync/engine.ts index 7aa7499..933bc85 100644 --- a/apps/postcards/src/lib/sync/engine.ts +++ b/apps/postcards/src/lib/sync/engine.ts @@ -12,8 +12,9 @@ // local write land through injected ports. That keeps it pure enough to unit-test // with an in-memory remote and keeps zero-lock-in honest (any git remote works). -import { gcTombstones, mergeById, type SyncSnapshot } from "./merge"; +import { canonicalJson, gcTombstones, mergeById, type SyncSnapshot } from "./merge"; import type { Story, Trip, Visit } from "../schema/models"; +import { MAX_PHOTOS_PER_VISIT, placeKey } from "../schema/helpers"; import { GitHubTarget, GitPushConflictError } from "../publish/gitTarget"; /** The three synced collections, each as a records+tombstones snapshot. */ @@ -136,10 +137,45 @@ const DEFAULT_GC_HORIZON_DAYS = 90; const ts = (r: R): string => r.updatedAt ?? r.addedAt; +/** + * One visit per place (FR-015), across devices too: two devices that marked the + * same place before syncing hold two visitIds for it, which a merge by id keeps + * both of. Keep the newest (ties by visitId, so every device picks the same one), + * join the others' photos onto it, and tombstone the others as of their own + * stamp so they stay deleted wherever they are still held. + */ +function onePerPlace(snap: SyncSnapshot): SyncSnapshot { + const byPlace = new Map(); + for (const v of snap.records) { + const k = placeKey(v.place); + byPlace.set(k, [...(byPlace.get(k) ?? []), v]); + } + if (byPlace.size === snap.records.length) return snap; + const records: Visit[] = []; + const tombstones = [...snap.tombstones]; + for (const group of byPlace.values()) { + group.sort((a, b) => ts(b).localeCompare(ts(a)) || a.visitId.localeCompare(b.visitId)); + const [kept, ...rest] = group as [Visit, ...Visit[]]; + if (!rest.length) { + records.push(kept); + continue; + } + const photos = [...(kept.photos ?? [])]; + for (const v of rest) { + for (const p of v.photos ?? []) if (!photos.some((q) => q.src === p.src)) photos.push(p); + tombstones.push({ id: v.visitId, deletedAt: ts(v) }); + } + records.push(photos.length ? { ...kept, photos: photos.slice(0, MAX_PHOTOS_PER_VISIT) } : kept); + } + records.sort((x, y) => x.visitId.localeCompare(y.visitId)); + tombstones.sort((x, y) => x.id.localeCompare(y.id)); + return { records, tombstones }; +} + /** Merge every collection newest-wins, honouring tombstones (reuses ./merge). */ function mergeAll(local: StoreSnapshots, remote: StoreSnapshots): StoreSnapshots { return { - visits: mergeById(local.visits, remote.visits, (v) => v.visitId, ts), + visits: onePerPlace(mergeById(local.visits, remote.visits, (v) => v.visitId, ts)), trips: mergeById(local.trips, remote.trips, (t) => t.tripId, ts), stories: mergeById(local.stories, remote.stories, (s) => s.storyId, ts), }; @@ -154,14 +190,15 @@ function gcAll(s: StoreSnapshots, horizonIso: string): StoreSnapshots { }; } -/** Deterministic string form of a snapshot set (records + tombstones sorted by id), - * independent of file metadata like exportedAt — used to detect "nothing to push". */ +/** Deterministic string form of a snapshot set (records + tombstones sorted by id, + * keys sorted), independent of file metadata like exportedAt — used to detect + * "nothing to push". */ function canonical(s: StoreSnapshots): string { const sortSnap = (snap: SyncSnapshot, idOf: (r: R) => string): SyncSnapshot => ({ records: [...snap.records].sort((a, b) => idOf(a).localeCompare(idOf(b))), tombstones: [...snap.tombstones].sort((a, b) => a.id.localeCompare(b.id)), }); - return JSON.stringify({ + return canonicalJson({ visits: sortSnap(s.visits, (v) => v.visitId), trips: sortSnap(s.trips, (t) => t.tripId), stories: sortSnap(s.stories, (s2) => s2.storyId), @@ -239,11 +276,14 @@ export async function syncOnce(ports: SyncPorts): Promise { } // If the remote already holds the converged set, there is nothing to push — - // idempotent re-sync is a genuine no-op (SC-005). - if (pulled.content != null && canonical(merged) === canonical(remoteSnap)) break; + // idempotent re-sync is a genuine no-op (SC-005). Compared as the file would + // read back once pushed: the import cleans text up, so a local copy it would + // change is the same record, not a new one to push on every run. + const text = serialize(merged); + if (pulled.content != null && canonical(parse(text)) === canonical(remoteSnap)) break; try { - await remote.push(serialize(merged), message, pulled.version); + await remote.push(text, message, pulled.version); break; } catch (err) { if (err instanceof SyncConflictError && attempt < maxRetries) { diff --git a/apps/postcards/src/lib/sync/merge.ts b/apps/postcards/src/lib/sync/merge.ts index 9343e79..9d665a1 100644 --- a/apps/postcards/src/lib/sync/merge.ts +++ b/apps/postcards/src/lib/sync/merge.ts @@ -22,6 +22,19 @@ export interface SyncSnapshot { tombstones: Tombstone[]; } +/** + * JSON with every object's keys sorted, so two copies of the same record compare + * equal however their keys were ordered (an edit appends keys; a parsed file has + * the schema's order). + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : v, + ); +} + /** Timestamp used to order two versions of the same record. */ function stamp(r: R, tsOf: (r: R) => string | null | undefined): string { return tsOf(r) ?? ""; @@ -29,7 +42,7 @@ function stamp(r: R, tsOf: (r: R) => string | null | undefined): string { /** * Pick the winner between two versions of the SAME record. Newest wins; on an - * exact timestamp tie we break deterministically by the JSON of the record, so + * exact timestamp tie we break deterministically by the canonical JSON, so * the outcome never depends on argument order (commutativity). */ function pick(a: R, b: R, tsOf: (r: R) => string | null | undefined): R { @@ -37,7 +50,7 @@ function pick(a: R, b: R, tsOf: (r: R) => string | null | undefined): R { const tb = stamp(b, tsOf); if (ta > tb) return a; if (tb > ta) return b; - return JSON.stringify(a) >= JSON.stringify(b) ? a : b; + return canonicalJson(a) >= canonicalJson(b) ? a : b; } /** diff --git a/apps/postcards/src/lib/sync/runSync.ts b/apps/postcards/src/lib/sync/runSync.ts index 52cdf78..fa7d192 100644 --- a/apps/postcards/src/lib/sync/runSync.ts +++ b/apps/postcards/src/lib/sync/runSync.ts @@ -12,19 +12,15 @@ // connector. `serialize` builds the portable file from records + tombstones only — // the token is NEVER part of it — so it can't leak into the pushed file or a backup. -import { normalizeVisitPhotos, backfillUpdatedAt } from "../schema/helpers"; +import { normalizeVisitPhotos, backfillUpdatedAt, stampDeletion } from "../schema/helpers"; import type { SyncTombstone } from "../schema/models"; -import { - getAllTombstones, - replaceAllPortable, - type TombstoneRecord, - type TombstoneKind, -} from "../db/visitsDb"; +import { getAllTombstones, mergeIntoPortable, type TombstoneKind } from "../db/visitsDb"; import { useVisits } from "../store/useVisits"; import { useTrips } from "../store/useTrips"; import { sortStories, useStories } from "../store/useStories"; import { useSyncStatus } from "../store/useSyncStatus"; import type { StoreSnapshots, SyncResult } from "./engine"; +import { mergeById, type SyncSnapshot } from "./merge"; import { markApplyingSync } from "./applyMark"; import { SYNC_PATH, shouldGuardRemoval, type RemoteConfig } from "./syncConfig"; @@ -38,6 +34,34 @@ const partitionTombs = (list: SyncTombstone[], kind: TombstoneKind) => const snapFor = (merged: StoreSnapshots, kind: TombstoneKind) => kind === "visit" ? merged.visits : kind === "trip" ? merged.trips : merged.stories; +/** + * Fold the edits made WHILE a run was in flight onto its merged result. The run + * merged a snapshot of the store taken before its pull, so a record added, edited + * or deleted since would otherwise be overwritten by that older copy. Store writes + * are immutable, so a record edited since is a new object; one gone from the store + * was deleted here and gets a tombstone as of now. Newest still wins. + */ +function withEditsSince( + merged: SyncSnapshot, + snapshot: R[], + current: R[], + idOf: (r: R) => string, +): SyncSnapshot { + if (current === snapshot) return merged; + const before = new Map(snapshot.map((r) => [idOf(r), r])); + const now = new Set(current.map(idOf)); + const edited = current.filter((r) => before.get(idOf(r)) !== r); + const deleted = snapshot + .filter((r) => !now.has(idOf(r))) + .map((r) => ({ id: idOf(r), deletedAt: stampDeletion(r) })); + return mergeById( + merged, + { records: edited, tombstones: deleted }, + idOf, + (r) => r.updatedAt ?? r.addedAt, + ); +} + /** The result of one run — a discriminated union so callers branch without relying * on thrown control-flow. `blocked` is the safety guard; `error` carries an i18n * code (see `sync.log.*`) so messages localise at render time. */ @@ -100,6 +124,13 @@ export async function runDeviceSync( import("./engine"), ]); + // A store still loading reads as empty, and persist would write that over the + // device: "Sync now" can be pressed before the startup read is done (auto-sync + // waits for it), so finish loading first. + await Promise.all( + [useVisits, useTrips, useStories].map((s) => !s.getState().loaded && s.getState().load()), + ); + const localTombs = await getAllTombstones(); const pickTombs = (kind: TombstoneKind) => partitionTombs(localTombs, kind); @@ -138,30 +169,44 @@ export async function runDeviceSync( }; const persist = async (merged: StoreSnapshots): Promise => { - const records: TombstoneRecord[] = kinds.flatMap((kind) => { - const snap = snapFor(merged, kind); - return snap.tombstones.map((t) => ({ - key: `${kind}:${t.id}`, - kind, - id: t.id, - deletedAt: t.deletedAt, - })); + // The user kept editing while the pull and push ran: keep those edits. They + // are pushed by the next run, which auto-sync schedules for any edit. + const withEdits = (): StoreSnapshots => ({ + visits: withEditsSince( + merged.visits, + local.visits.records, + useVisits.getState().visits, + (v) => v.visitId, + ), + trips: withEditsSince( + merged.trips, + local.trips.records, + useTrips.getState().trips, + (t) => t.tripId, + ), + stories: withEditsSince( + merged.stories, + local.stories.records, + useStories.getState().stories, + (s) => s.storyId, + ), }); - // Records AND tombstones in one transaction (FR-015). - await replaceAllPortable( - merged.visits.records, - merged.trips.records, - merged.stories.records, - records, - ); + // Records AND tombstones in one transaction (FR-015), with what another tab + // wrote since this run read the stores merged in rather than written over. + await mergeIntoPortable(withEdits(), local); + // An edit made during that write lands in IndexedDB in its own, later + // transaction; fold it into memory too. + const inMemory = withEdits(); // Bracket the store writes so auto-sync's own subscribers ignore them and // don't mistake a persisted pull for a fresh local edit (would loop forever). markApplyingSync(() => { useVisits.setState({ - visits: merged.visits.records.map(normalizeVisitPhotos).map(backfillUpdatedAt), + visits: inMemory.visits.records.map(normalizeVisitPhotos).map(backfillUpdatedAt), + }); + useTrips.setState({ trips: inMemory.trips.records.map(backfillUpdatedAt) }); + useStories.setState({ + stories: sortStories(inMemory.stories.records.map(backfillUpdatedAt)), }); - useTrips.setState({ trips: merged.trips.records.map(backfillUpdatedAt) }); - useStories.setState({ stories: sortStories(merged.stories.records.map(backfillUpdatedAt)) }); }); }; diff --git a/apps/postcards/src/lib/wikivoyage/fulltext.ts b/apps/postcards/src/lib/wikivoyage/fulltext.ts index 82a2bdd..e4a3bc0 100644 --- a/apps/postcards/src/lib/wikivoyage/fulltext.ts +++ b/apps/postcards/src/lib/wikivoyage/fulltext.ts @@ -122,7 +122,7 @@ export async function fetchFullText( const resolvedTitle = page.title ?? title; return { title: resolvedTitle, - url: articleUrl(resolvedTitle, lang), + url: articleUrl(resolvedTitle, lang, undefined, project), attribution: `${project === "wikipedia" ? "Wikipedia" : "Wikivoyage"} · CC BY-SA 4.0`, sections, }; diff --git a/apps/postcards/src/lib/wikivoyage/summary.ts b/apps/postcards/src/lib/wikivoyage/summary.ts index 3bcc198..0ef833e 100644 --- a/apps/postcards/src/lib/wikivoyage/summary.ts +++ b/apps/postcards/src/lib/wikivoyage/summary.ts @@ -67,7 +67,7 @@ export async function fetchSummary( return { title: j.title ?? title, extract, - url: j.content_urls?.desktop?.page ?? articleUrl(title, lang), + url: j.content_urls?.desktop?.page ?? articleUrl(title, lang, undefined, project), attribution: `${project === "wikipedia" ? "Wikipedia" : "Wikivoyage"} · CC BY-SA 4.0`, ...(thumb ? { thumb } : {}), }; diff --git a/apps/postcards/src/lib/wikivoyage/urls.ts b/apps/postcards/src/lib/wikivoyage/urls.ts index a718f9d..69baa5f 100644 --- a/apps/postcards/src/lib/wikivoyage/urls.ts +++ b/apps/postcards/src/lib/wikivoyage/urls.ts @@ -3,8 +3,11 @@ export const DEFAULT_LANG = "en"; -function host(lang: string): string { - return `https://${lang}.wikivoyage.org`; +/** Wikimedia sister project a summary can come from. */ +export type WikiProject = "wikivoyage" | "wikipedia"; + +function host(lang: string, project: WikiProject = "wikivoyage"): string { + return `https://${lang}.${project}.org`; } /** @@ -15,9 +18,15 @@ export function titleToPath(title: string): string { return encodeURIComponent(title.trim().replace(/\s+/g, "_")).replace(/%2F/g, "/"); } -/** URL of an article (optionally to a section anchor). */ -export function articleUrl(title: string, lang: string = DEFAULT_LANG, section?: string): string { - const base = `${host(lang)}/wiki/${titleToPath(title)}`; +/** URL of an article (optionally to a section anchor), on Wikivoyage unless + * another project is named (the Wikipedia fallback links to Wikipedia). */ +export function articleUrl( + title: string, + lang: string = DEFAULT_LANG, + section?: string, + project: WikiProject = "wikivoyage", +): string { + const base = `${host(lang, project)}/wiki/${titleToPath(title)}`; return section ? `${base}#${titleToPath(section)}` : base; } @@ -31,9 +40,6 @@ export function searchUrl(query: string, lang: string = DEFAULT_LANG): string { return `${host(lang)}/w/index.php?search=${encodeURIComponent(query)}`; } -/** Wikimedia sister project a summary can come from. */ -export type WikiProject = "wikivoyage" | "wikipedia"; - /** REST summary endpoint for a title (used by the opt-in online fetch). */ export function summaryEndpoint( title: string, diff --git a/apps/postcards/src/main.tsx b/apps/postcards/src/main.tsx index 643d5f5..20fb86b 100644 --- a/apps/postcards/src/main.tsx +++ b/apps/postcards/src/main.tsx @@ -1,57 +1,25 @@ import { lazy, StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./app/App"; import { CANONICAL_URL, MOVED_FLAG, movedTarget } from "./lib/moved/moved"; import { initReferenceData } from "./lib/reference/referenceData"; -import { useUpdate } from "./lib/store/useUpdate"; -import { useSettings } from "./lib/store/useSettings"; +import { registerServiceWorker } from "./lib/serviceWorker"; import { initDurability } from "./lib/db/initDurability"; +import { LoadBoundary, LoadFailure } from "./ui/LoadFailure"; import "@fontsource-variable/inter"; // self-hosted (OFL) — no font CDN import "@fontsource-variable/space-grotesk"; // display face for the wordmark, headings & figures (OFL) import "maplibre-gl/dist/maplibre-gl.css"; import "./styles.css"; -// Keep open tabs off a stale cached build. We register the generated service -// worker ourselves (no workbox-window dependency): when a NEW build installs and -// waits, we surface a "new version — reload" banner (see UpdateBanner) rather -// than swapping code under the user. Tapping Reload posts SKIP_WAITING (the SW -// listens for it), which activates the new worker; `controllerchange` then -// reloads the page once into the fresh build. A 30-min poll lets an always-open -// tab discover a deploy without a manual reload. The very first install (no -// existing controller) never prompts — it just primes the offline cache. +// Keep open tabs off a stale cached build: the service worker, its update banner +// and the deploy poll are wired in lib/serviceWorker. if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - let reloading = false; - navigator.serviceWorker.addEventListener("controllerchange", () => { - if (reloading) return; - reloading = true; - window.location.reload(); - }); - const offerIfWaiting = (worker: ServiceWorker | null) => { - if (worker && worker.state === "installed" && navigator.serviceWorker.controller) { - useUpdate.getState().announce(() => worker.postMessage({ type: "SKIP_WAITING" })); - } - }; - navigator.serviceWorker - .register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL }) - .then((reg) => { - offerIfWaiting(reg.waiting); - reg.addEventListener("updatefound", () => { - const nw = reg.installing; - nw?.addEventListener("statechange", () => offerIfWaiting(nw)); - }); - // Poll for a fresh deploy — but NOT in Offline mode, where the app makes - // zero app-initiated network requests (the update check is optional egress). - setInterval(() => { - if (!useSettings.getState().offlineMode) void reg.update(); - }, 30 * 60 * 1000); - }) - .catch(() => { - /* registration unavailable (insecure context / unsupported): app still works */ - }); - }); + window.addEventListener("load", registerServiceWorker); } +// The module graph ran, so the boot guard (public/boot-guard.js) stands down; +// from here a failed load shows LoadFailure. +(window as { __postcardsBootGuard?: { cancel(): void } }).__postcardsBootGuard?.cancel(); + const el = document.getElementById("root"); if (!el) throw new Error("Root element not found"); @@ -71,9 +39,11 @@ if (redirect && CANONICAL_URL && movedBefore) { const MovedScreen = lazy(() => import("./features/moved/MovedScreen")); createRoot(el).render( - - - + + + + + , ); } else { @@ -85,18 +55,32 @@ function bootApp(el: HTMLElement) { // we can request persistent storage on first real data and track backup freshness. initDurability(); - // Load the bundled reference gazetteer (local, SW-cached) before first render - // so every screen can read it synchronously. - void initReferenceData().then(() => { - createRoot(el).render( - - - , - ); - // Merge any installed community data packs into the reference set (off the - // critical path; fires the gazetteer event so screens refresh when it lands). - void import("./lib/packs/store").then((m) => m.useDataPacks.getState().load()); - }); + // Load the app's code and the bundled reference gazetteer (local, SW-cached) + // before first render so every screen can read it synchronously. The app is + // imported here, not statically, so a chunk that fails to download shows a + // reload screen rather than a blank page. + const root = createRoot(el); + Promise.all([import("./app/App"), initReferenceData()]) + .then(([{ App }]) => { + root.render( + + + , + ); + // Merge any installed community data packs into the reference set (off the + // critical path; fires the gazetteer event so screens refresh when it lands). + void import("./lib/packs/store").then((m) => m.useDataPacks.getState().load()); + // Whole guides saved in localStorage by earlier builds filled it until + // settings stopped saving; move them to their IndexedDB store. + void import("./features/guides/guideCache").then((m) => m.moveLocalGuides()); + }) + .catch(() => { + root.render( + + + , + ); + }); // Warm the code-split MapScreen chunk (~1 MB, mostly MapLibre) — the map is the // default tab, so it's needed next. Deferred to idle so its ~1 MB fetch+parse diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 4b2be32..12e1b76 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -1456,8 +1456,7 @@ h1.brand { } /* One card grammar for every grouped section (hero, continents, records, travel): surface + border + a real

    head so heading navigation works. */ -.stats-section, -.dash-card { +.stats-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); @@ -1465,8 +1464,7 @@ h1.brand { padding: 14px; margin-bottom: 12px; } -.stats-section > h3, -.dash-card > h3 { +.stats-section > h3 { margin: 0 0 10px; font-size: 15px; font-weight: 600; @@ -1730,23 +1728,6 @@ h1.brand { text-decoration: underline; color: var(--accent); } -.country-tags { - grid-area: tags; - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 4px; -} -.country-tag { - font-size: 11.5px; - font-weight: 600; - color: var(--muted); - background: var(--bg-sub); - border-radius: 999px; - padding: 2px 8px; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} .country-caret { grid-area: caret; color: var(--muted); @@ -1950,44 +1931,6 @@ h1.brand { .tier-grid .metric-label .muted { font-size: 10.5px; } -/* "What's left to explore" as plain, scannable text — a label + count, then - names as accent links joined by thin separators. Far lighter than the old - rounded-chip wall, and it wraps cleanly on a narrow phone. */ -.name-list { - margin-top: 12px; - font-size: 12.5px; - line-height: 1.75; -} -.name-list-label { - display: block; - font-weight: 600; - color: var(--text); - margin-bottom: 1px; -} -.name-list-label .muted { - font-weight: 400; -} -.name-list-names { - color: var(--muted); -} -.name-list-sep { - color: var(--border); -} -.name-list-link { - background: none; - border: none; - padding: 0; - font: inherit; - color: var(--accent); - font-weight: 500; -} -.name-list-link:hover { - text-decoration: underline; -} -.name-list-more { - font-weight: 600; - margin-left: 4px; -} .metric { margin-top: 8px; } @@ -2075,6 +2018,11 @@ h1.brand { .btn-ghost:active { transform: translateY(1px); } +/* A link styled as a ghost button (Settings → Android app). */ +a.btn-ghost { + display: inline-block; + text-decoration: none; +} .notice { background: var(--bg-sub); border: 1px solid var(--border); @@ -2092,6 +2040,13 @@ h1.brand { border-color: var(--notice-ok-border); background: var(--visited-soft); } +/* In place of app code that failed to download (ui/LoadFailure). */ +.load-failure { + max-width: 32rem; + margin: 0 auto; + padding: 32px 16px; + text-align: center; +} /* Backup reminder: a gentle, dismissible nudge at the top of Your data. */ .backup-reminder { display: flex; @@ -2443,22 +2398,6 @@ h1.brand { } } -/* Country drill-down */ -.country-detail { - margin-top: 10px; - border-top: 1px solid var(--border); - padding-top: 8px; -} -.country-detail summary { - cursor: pointer; - font-size: 13px; - color: var(--muted); - font-weight: 600; -} -.country-detail p { - margin: 8px 0 0; -} - /* "How it works" modal */ .about-modal { max-width: 540px; @@ -2846,27 +2785,9 @@ h1.brand { opacity: 0.35; cursor: not-allowed; } -.trip-distance { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; - padding: 8px 10px; - border: 1px dashed var(--border-strong); - border-radius: var(--radius-sm); -} -.trip-distance-label { - font-size: 13px; - color: var(--text-muted, inherit); -} .trip-distance-km { font-size: 18px; } -.trip-composer-fields { - display: flex; - flex-direction: column; - gap: 12px; -} .field { display: flex; flex-direction: column; @@ -3012,10 +2933,6 @@ h1.brand { text-overflow: ellipsis; white-space: nowrap; } -.myplaces-legend { - max-height: 200px; - overflow-y: auto; -} /* Trip folders — group legs under their shared trip name. */ .trip-folders { display: flex; @@ -3285,46 +3202,6 @@ h1.brand { .record-emoji { flex: none; } -.chip-row { - display: flex; - gap: 8px; - align-items: baseline; - margin: 7px 0 0; -} -.chip-row-label { - flex: none; - font-size: 12px; - font-weight: 600; - color: var(--muted); - min-width: 104px; -} -.chip-row-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; -} -.place-chip { - font-size: 12px; - font-weight: 600; - padding: 2px 9px; - border-radius: 999px; - border: 1px solid var(--border-strong); - background: var(--surface); - color: var(--text); -} -.place-chip.chip-done { - background: var(--visited-soft); - border-color: var(--visited); - color: var(--visited); -} -.place-chip:not(:disabled):hover { - border-color: var(--accent); - color: var(--accent); -} -.place-chip.chip-more { - border-style: dashed; - color: var(--muted); -} /* Passport: flags. */ .passport-head { @@ -4711,13 +4588,6 @@ button.brand:focus-visible { .bar { height: 5px; } -.chip-row { - margin: 3px 0; -} -.place-chip { - padding: 2px 8px; - font-size: 12px; -} .section-head + .muted.small { margin-top: 0; } @@ -4947,10 +4817,7 @@ button.brand:focus-visible { .city-row.compact { padding: 5px 0; } - /* Stats: give the chips and the record-city fly-to links comfortable targets. */ - .place-chip { - min-height: 34px; - } + /* Stats: give the record-city fly-to links comfortable targets. */ .record .country-open { min-height: 34px; } @@ -5293,28 +5160,6 @@ button.brand:focus-visible { font-size: 13px; color: rgba(234, 240, 255, 0.6); } -.intro-get { - flex: none; - border: 1px solid rgba(150, 185, 255, 0.5); - background: rgba(120, 160, 255, 0.16); - color: #dfe8ff; - border-radius: 999px; - padding: 7px 16px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; -} -.intro-get:hover:not(:disabled) { - background: rgba(120, 160, 255, 0.3); -} -.intro-get:disabled { - opacity: 0.7; - cursor: default; - border-color: rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.06); - color: rgba(234, 240, 255, 0.75); -} /* On/off toggle switch — the intro's single control type, so every optional feature reads the same: flip to activate, flip back to deactivate. */ .intro-switch { diff --git a/apps/postcards/src/ui/Attribution.tsx b/apps/postcards/src/ui/Attribution.tsx index 7956b32..4a72507 100644 --- a/apps/postcards/src/ui/Attribution.tsx +++ b/apps/postcards/src/ui/Attribution.tsx @@ -1,12 +1,14 @@ import { useMemo } from "react"; import { getReferenceData } from "../lib/reference/referenceData"; +import { useT } from "../lib/i18n"; /** Surfaces reference-dataset provenance (Constitution I & Data Standards). */ export function Attribution() { + const t = useT(); const ref = useMemo(() => getReferenceData(), []); return (
    - Data sources:{" "} + {t("attribution.label")}{" "} {ref.provenance.map((p, i) => ( {i > 0 ? " · " : ""} diff --git a/apps/postcards/src/ui/FilterPanel.tsx b/apps/postcards/src/ui/FilterPanel.tsx index 2959313..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,10 +38,16 @@ 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(); 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 +59,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 +82,7 @@ export function FilterPanel({ document.removeEventListener("keydown", onKey, true); opener?.focus?.(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; @@ -87,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 (
    @@ -182,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")}
    @@ -231,7 +246,7 @@ export function FilterPanel({ )} {/* Folder / trip */} - {folders.length > 0 && ( + {!hidden.includes("folder") && folders.length > 0 && (