Skip to content

Commit 4c13a2a

Browse files
committed
Simplify: second batch of safe, behavior-preserving cleanups
More from the app-wide analysis — each type-checked and test-covered, no behavior change: - Shared helpers replace duplicated logic: `placeFlag` (trip flag emoji, was copied in three travel components), `nullableSanitized`/`optionalLabel` (Zod transforms in models.ts, were repeated 4×/3× — the generated JSON-Schema artifact is byte-identical), a generic `ts` accessor in sync/engine, and `partitionTombs`/`snapFor` in sync/runSync. - offline/tiles: one `defaultFetch`, one `prefetchDisabled()` guard, and one `runPrefetchPool()` replacing two near-identical 2-worker loops. - Dead code / tighter surface: drop the unused `MOMENT_GROUP_ORDER`, the write- only `BrowseRow.lat/lon`, the `PackPlace` type, the never-passed `hint` prop on the stats NameList; collapse `fullCitiesOptedIn`/`fullCitiesEnabled` into one; drop unreferenced i18n barrel re-exports and the `export` on `MAX_PACK_PLACES`. - Smaller reads: PassportScreen counts instead of allocating; PublishScreen reuses `passNorm`; importJson renames a shadowing loop var; guideNames gets its explicit return type; photoBlobs' decode is one loop. Gate: tsc clean, 459 unit tests (incl. schema-artifact + import-security), e2e green (trip-reconstruction, trip-routemap, offline, a11y).
1 parent f45ae34 commit 4c13a2a

20 files changed

Lines changed: 131 additions & 179 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import {
1111
} from "../../lib/schema/models";
1212
import { getReferenceData } from "../../lib/reference/referenceData";
1313

14+
/** Drop an empty `photos` array so a photo-less record stays lean in the file. */
15+
function dropEmptyPhotos<T extends { photos?: unknown[] }>(rec: T): T | Omit<T, "photos"> {
16+
const { photos, ...rest } = rec;
17+
return photos && photos.length ? { ...rest, photos } : rest;
18+
}
19+
1420
/** Build the canonical portable file object from the current visits + trips + stories.
1521
* `tombstones` is written only for device sync; a plain backup passes none, so the
1622
* exported file stays free of an empty `tombstones` key. */
@@ -32,9 +38,9 @@ export function buildFile(
3238
schemaVersion: SCHEMA_VERSION,
3339
exportedAt: now.toISOString(),
3440
// Drop empty `photos` arrays so a photo-less export stays lean and readable.
35-
visits: visits.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)),
41+
visits: visits.map(dropEmptyPhotos),
3642
trips,
37-
stories: stories.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)),
43+
stories: stories.map(dropEmptyPhotos),
3844
...(tombstones.length ? { tombstones } : {}),
3945
referenceSources,
4046
};

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ export function importFile(text: string): ImportResult {
8282
// lists a place twice, keep the first record's identity but UNION the galleries
8383
// (photos are now the payload — dropping one silently would lose data).
8484
const byPlace = new Map<string, Visit>();
85-
for (const raw of parsed.data.visits) {
86-
const v = normalizeVisitPhotos(raw);
85+
for (const rawVisit of parsed.data.visits) {
86+
const v = normalizeVisitPhotos(rawVisit);
8787
const key = placeKey(v.place);
8888
const existing = byPlace.get(key);
8989
if (!existing) {

apps/postcards/src/features/guides/GuideButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine;
2929

3030
/** Resolve the names a place's guides are built from (common country name —
3131
* the real Wikivoyage article title, e.g. "Russia", not "Russian Federation"). */
32-
function guideNames(place: PlaceRef) {
32+
function guideNames(place: PlaceRef): GuideNames | null {
3333
const ref = getReferenceData();
3434
const country = ref.countryByIso2(place.countryId);
3535
if (!country) return null;

apps/postcards/src/features/passport/PassportScreen.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {
7070
}, [posterUrl]);
7171

7272
const visitedIds = useMemo(() => visitedCountryIds(visits), [visits]);
73-
const { collected, missing, continents } = useMemo(() => {
73+
const { collectedCount, missing, continents } = useMemo(() => {
7474
const all = ref.countries.filter((c) => inScope(c.sovereignty, scope));
75-
const collected = all.filter((c) => visitedIds.has(c.iso2));
75+
const collectedCount = all.filter((c) => visitedIds.has(c.iso2)).length;
7676
const missing = all.filter((c) => !visitedIds.has(c.iso2));
7777
// Collected flags grouped by continent, each with its own progress, so the
7878
// passport reads like pages of a real one.
@@ -88,7 +88,7 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {
8888
.filter(([, g]) => g.done.length > 0)
8989
.map(([name, g]) => ({ name, done: g.done, total: g.total }))
9090
.sort((a, b) => b.done.length - a.done.length || a.name.localeCompare(b.name));
91-
return { collected, missing, continents };
91+
return { collectedCount, missing, continents };
9292
}, [ref, visitedIds, scope]);
9393
const [shownMissing, setShownMissing] = useState(60);
9494

@@ -152,15 +152,15 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {
152152

153153
<div className="passport-head">
154154
<p className="muted">
155-
<strong className="flags-count">{formatInt(collected.length)}</strong>{" "}
156-
{t("passport.ofFlags", { total: formatInt(collected.length + missing.length) })}
155+
<strong className="flags-count">{formatInt(collectedCount)}</strong>{" "}
156+
{t("passport.ofFlags", { total: formatInt(collectedCount + missing.length) })}
157157
</p>
158158
<button className="btn" type="button" disabled={rendering} onClick={() => void exportPoster()}>
159159
{rendering ? t("passport.rendering") : `🖼 ${t("passport.worldPoster")}`}
160160
</button>
161161
</div>
162162

163-
{collected.length === 0 ? (
163+
{collectedCount === 0 ? (
164164
<p className="muted empty">
165165
<span className="empty-emoji" aria-hidden>
166166
🛂

apps/postcards/src/features/publish/PublishScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,11 @@ export function PublishScreen({ onClose }: { onClose: () => void }) {
213213

214214
/** Build the final self-contained HTML (encrypted when a passphrase is set). */
215215
async function buildHtml(): Promise<string> {
216-
// Normalise ONCE and use the SAME value for the encrypt decision and the
216+
// Use the SAME normalised value (passNorm) for the encrypt decision and the
217217
// encryption itself. Before, the decision used passphrase.trim() but the
218218
// encrypt used the raw value: a spaces-only box silently published PLAINTEXT,
219219
// and surrounding spaces produced a file that could never be unlocked.
220-
const pass = passphrase.normalize("NFC").trim();
220+
const pass = passNorm;
221221
if (pass) {
222222
if (pass.length < MIN_PASSPHRASE_LENGTH) {
223223
throw new Error(`Use a passphrase of at least ${MIN_PASSPHRASE_LENGTH} characters.`);

apps/postcards/src/features/stats/StatsView.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,17 @@ function NameList({
3131
label,
3232
items,
3333
onPick,
34-
hint,
3534
max = 12,
3635
}: {
3736
label: string;
3837
items: string[];
3938
onPick: (name: string) => void;
40-
hint?: string;
4139
max?: number;
4240
}) {
4341
const t = useT();
4442
const [expanded, setExpanded] = useState(false);
4543
if (items.length === 0) return null;
4644
const shown = expanded ? items : items.slice(0, max);
47-
const hintText = hint ?? t("common.open");
4845
return (
4946
<div className="name-list">
5047
<span className="name-list-label">
@@ -62,7 +59,7 @@ function NameList({
6259
type="button"
6360
className="name-list-link"
6461
onClick={() => onPick(n)}
65-
title={`${hintText} ${n}`}
62+
title={`${t("common.open")} ${n}`}
6663
>
6764
{n}
6865
</button>

apps/postcards/src/features/travel/MyPlacesPicker.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { useDeferredValue, useMemo, useState } from "react";
22
import { getReferenceData } from "../../lib/reference/referenceData";
33
import { searchPlaces } from "../visits/search";
4-
import { countryFlag } from "../../lib/format/format";
54
import { RouteMap } from "./RouteMap";
65
import { useT } from "../../lib/i18n";
76
import type { PlaceRef, TravelMode } from "../../lib/schema/models";
8-
import type { MyPlace } from "./myPlaces";
7+
import { placeFlag, type MyPlace } from "./myPlaces";
98

109
// Pick trip stops fast. Two ways:
1110
// • List — the places you've BEEN (visited + past trips) as instant taps, AND a
@@ -15,8 +14,6 @@ import type { MyPlace } from "./myPlaces";
1514
// pin to add it in sequence and watch the route draw (see RouteMap).
1615
// Flags everywhere for instant recognition (spec 019).
1716

18-
const flagFor = (p: PlaceRef) => (p.kind === "airport" ? "✈️" : countryFlag(p.countryId));
19-
2017
export function MyPlacesPicker({
2118
places,
2219
addedKeys,
@@ -92,7 +89,7 @@ export function MyPlacesPicker({
9289
onClick={() => onPick(r.place)}
9390
>
9491
<span className="flag" aria-hidden>
95-
{flagFor(r.place)}
92+
{placeFlag(r.place)}
9693
</span>
9794
<span className="myplaces-name">{r.place.name}</span>
9895
<span className="muted small myplaces-detail">{r.detail}</span>
@@ -114,7 +111,7 @@ export function MyPlacesPicker({
114111
onClick={() => onPick(p.place)}
115112
>
116113
<span className="flag" aria-hidden>
117-
{flagFor(p.place)}
114+
{placeFlag(p.place)}
118115
</span>
119116
<span className="myplaces-name">{p.name}</span>
120117
{addedKeys.has(p.key) && (

apps/postcards/src/features/travel/RouteMap.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import maplibregl, { type StyleSpecification } from "maplibre-gl";
33
import { getReferenceData } from "../../lib/reference/referenceData";
44
import { useSettings } from "../../lib/store/useSettings";
55
import { usePrefersReducedMotion } from "../../lib/hooks/usePrefersReducedMotion";
6-
import { countryFlag } from "../../lib/format/format";
76
import { stopsArcs } from "../map/visitedLayers";
87
import { fitBounds } from "../map/mapFit";
98
import { useT } from "../../lib/i18n";
109
import type { PlaceRef, TravelMode } from "../../lib/schema/models";
11-
import type { MyPlace } from "./myPlaces";
10+
import { placeFlag, type MyPlace } from "./myPlaces";
1211
import { getLand } from "./landGeometry";
1312
import { pickPointsFC } from "./pickPoints";
1413

@@ -22,8 +21,6 @@ import { pickPointsFC } from "./pickPoints";
2221
// places (real <button>s with flags) sits beneath the canvas as the keyboard/AT
2322
// route, and an aria-live region announces every add.
2423

25-
const flagFor = (p: PlaceRef) => (p.kind === "airport" ? "✈️" : countryFlag(p.countryId));
26-
2724
function resolveDark(theme: "system" | "light" | "dark"): boolean {
2825
if (theme === "dark") return true;
2926
if (theme === "light") return false;
@@ -214,7 +211,7 @@ export function RouteMap({
214211
onClick={() => onPick(p.place)}
215212
>
216213
<span className="flag" aria-hidden>
217-
{flagFor(p.place)}
214+
{placeFlag(p.place)}
218215
</span>
219216
<span className="myplaces-name">{p.name}</span>
220217
{addedKeys.has(p.key) && (

apps/postcards/src/features/travel/TripComposer.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { getReferenceData } from "../../lib/reference/referenceData";
33
import { useTrips } from "../../lib/store/useTrips";
44
import { useVisits } from "../../lib/store/useVisits";
55
import { placeKey } from "../../lib/schema/helpers";
6-
import { countryFlag } from "../../lib/format/format";
76
import { useT, useLocale } from "../../lib/i18n";
87
import type { PlaceRef, TravelMode } from "../../lib/schema/models";
98
import { MyPlacesPicker } from "./MyPlacesPicker";
10-
import { myPlaces } from "./myPlaces";
9+
import { myPlaces, placeFlag } from "./myPlaces";
1110
import { addStop, moveStop, removeStop } from "./tripStops";
1211
import { tripPathKm } from "./distance";
1312
import { MODE_ORDER } from "./modes";
@@ -112,7 +111,7 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo
112111
{i + 1}
113112
</span>
114113
<span className="flag" aria-hidden>
115-
{s.kind === "airport" ? "✈️" : countryFlag(s.countryId)}
114+
{placeFlag(s)}
116115
</span>
117116
<span className="trip-stop-name" title={s.name}>
118117
{s.name}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { PlaceRef, Trip, Visit } from "../../lib/schema/models";
22
import type { ReferenceData } from "../../lib/reference/types";
33
import { placeKey } from "../../lib/schema/helpers";
4+
import { countryFlag } from "../../lib/format/format";
5+
6+
/** The emoji that stands in for a place in the trip UI — a plane for airports,
7+
* else the country flag. One definition shared by every trip picker/row. */
8+
export const placeFlag = (p: PlaceRef): string =>
9+
p.kind === "airport" ? "✈️" : countryFlag(p.countryId);
410

511
// The pool the trip composer picks stops from (spec 019, fast-reconstruction): ONLY
612
// places you've already been — your visited records plus every place already used in

0 commit comments

Comments
 (0)