Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/postcards/src/features/backup/Backup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function Backup() {
setMessage({ kind: "err", text: result.error });
return;
}
if (visits.length > 0 || trips.length > 0 || stories.length > 0) {
if (hasData) {
const ok = window.confirm(
t("backup.confirm.replace", {
curPlaces: visits.length,
Expand Down
9 changes: 6 additions & 3 deletions apps/postcards/src/features/guides/GuideButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const KIND_GROUP: Record<string, "explore" | "understand" | "phrasebook"> = {
};
const GROUP_ORDER = ["explore", "understand", "phrasebook"] as const;

/** Whether the device is offline right now (guides are online-only, opt-in). */
const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine;

/** Resolve the names a place's guides are built from (common country name —
* the real Wikivoyage article title, e.g. "Russia", not "Russian Federation"). */
function guideNames(place: PlaceRef) {
Expand Down Expand Up @@ -203,7 +206,7 @@ function GuideContent({ placeName, names }: { placeName: string; names: GuideNam
const tried = useRef(false);
useEffect(() => {
if (tried.current || summary || wpSummary || !autoLoad) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
if (isOffline()) return;
tried.current = true;
void loadOverview();
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -271,7 +274,7 @@ function GuideContent({ placeName, names }: { placeName: string; names: GuideNam
)}
{!overview && state === "empty" && (
<p className="muted small">
{typeof navigator !== "undefined" && !navigator.onLine
{isOffline()
? t("guide.emptyOffline")
: t("guide.emptyOnline")}{" "}
<button type="button" className="mini-btn" onClick={loadOverview}>
Expand Down Expand Up @@ -321,7 +324,7 @@ function GuideContent({ placeName, names }: { placeName: string; names: GuideNam
)}
{fullState === "empty" && (
<p className="muted small">
{typeof navigator !== "undefined" && !navigator.onLine
{isOffline()
? t("guide.fullOffline")
: t("guide.fullEmpty")}{" "}
<button type="button" className="mini-btn" onClick={() => void loadFullGuide()}>
Expand Down
3 changes: 1 addition & 2 deletions apps/postcards/src/features/map/MapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { MapView, hasSavedCamera, type Basemap, type MapFocus, type MapFit } fro
import { tripArcs } from "./visitedLayers";
import { fitBounds } from "./mapFit";
import { dateBuckets, mapDateMatches, rangeExactYear, type MapDate } from "../travel/period";
import { citiesInView, type Bounds } from "./viewport";
import { citiesInView, IN_VIEW_CAP, type Bounds } from "./viewport";
import { bundledMapSource } from "../../lib/map-source/bundledMapSource";
import type { City } from "../../lib/reference/types";
import type { PlaceRef } from "../../lib/schema/models";
Expand All @@ -42,7 +42,6 @@ import { useT, type MessageKey } from "../../lib/i18n";
// most relevant cities) — reactions to a toggle stay instant even at world
// zoom instead of recounting 135k rows.
const PAGE = 30;
const IN_VIEW_CAP = 2000;
const POI_LIST_CAP = 50;
const collator = new Intl.Collator(); // hoisted: per-pair localeCompare over 135k rows janks pans
const BASEMAP_KEY = "postcards-basemap";
Expand Down
6 changes: 2 additions & 4 deletions apps/postcards/src/features/map/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export interface MapFit {
}

export type Basemap = "simple" | "osm" | "detail";
export type MapMode = "all" | "cities" | "monuments" | "airports";
type MapMode = "all" | "cities" | "monuments" | "airports";

const MODE_LAYERS: Record<Exclude<MapMode, "all">, string[]> = {
cities: ["cities-visited", "cities-inview", "cities-all", "cities-wishlist"],
Expand Down Expand Up @@ -1237,9 +1237,7 @@ export function MapView({
}

function applyTripArcs(map: MlMap) {
(map.getSource("trip-arcs") as GeoJSONSource | undefined)?.setData(
tripArcsRef.current ?? { type: "FeatureCollection", features: [] },
);
(map.getSource("trip-arcs") as GeoJSONSource | undefined)?.setData(tripArcsRef.current ?? EMPTY_FC);
}

function applyTheme(map: MlMap, isDark: boolean) {
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/features/map/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function citiesInView(

/** The personal statuses the map shows (multi-select; empty = show all). Kept as
* a bare string list so viewport.ts stays free of store imports. */
export type CityStatus = "visited" | "wishlist" | "unvisited";
type CityStatus = "visited" | "wishlist" | "unvisited";

/** Working set considered "in view" before the on-map marker cap — the same
* size the MapScreen list snapshots, so the map dots and the list stay in
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/features/passport/poster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function flagAnchor(geom: Polygon | MultiPolygon): [number, number] {
return project(best!);
}

export interface PosterStats {
interface PosterStats {
countries: number;
cities: number;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/features/settings/SyncSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function SyncSection() {
}

async function onDownloadData() {
const [{ serializeFile }] = await Promise.all([import("../backup/exportJson")]);
const { serializeFile } = await import("../backup/exportJson");
const text = serializeFile(
useVisits.getState().visits,
useTrips.getState().trips,
Expand Down
9 changes: 2 additions & 7 deletions apps/postcards/src/features/stats/StatStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useUi, type PlacesView } from "../../lib/store/useUi";
import { useFilters } from "../../lib/store/useFilters";
import { getReferenceData } from "../../lib/reference/referenceData";
import { computeCoverage } from "./computeStats";
import { formatInt, formatPercent } from "../../lib/format/format";
import { formatInt, formatPercent, formatPercentFloor } from "../../lib/format/format";
import { useT } from "../../lib/i18n";

/** Compact counter strip. Every counter is a shortcut: tap it to open the
Expand Down Expand Up @@ -46,12 +46,7 @@ export function StatStrip() {
}) {
// A tiny-but-nonzero coverage rounds to "0%", which reads as "none visited"
// even after you've been somewhere. Floor it to "<1%" (same as the hero).
const pctLabel =
pct != null && pct > 0 && formatPercent(pct) === formatPercent(0)
? "<1%"
: pct != null
? formatPercent(pct)
: null;
const pctLabel = pct != null ? formatPercentFloor(pct) : null;
const aria =
pct != null
? t("statStrip.visitedAria", {
Expand Down
28 changes: 8 additions & 20 deletions apps/postcards/src/features/stats/StatsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { travelTotals } from "../travel/distance";
import { MODE_GLYPH } from "../travel/modes";
import { useUi, type PlacesView } from "../../lib/store/useUi";
import { useFilters } from "../../lib/store/useFilters";
import { countryFlag, formatDate, formatInt, formatKm, formatPercent } from "../../lib/format/format";
import { countryFlag, formatDate, formatInt, formatKm, formatPercent, formatPercentFloor } from "../../lib/format/format";
import { CONTINENT_COLORS, CONTINENT_ORDER } from "../../lib/reference/continents";
import { ScopeToggle } from "../../ui/ScopeToggle";
import { useT, type MessageKey } from "../../lib/i18n";
Expand Down Expand Up @@ -148,18 +148,12 @@ function CountryRow({
useFilters.getState().set({ country: c.iso2, minPop, listOnly: false });
useUi.getState().openPlaces(view);
};
// Cities coverage is a sliver of a huge denominator, so it often rounds to 0 —
// floor a real, non-zero share to "<1%" so it never reads as "nothing seen".
const pctText = (p: number) => {
const s = formatPercent(p);
return p > 0 && s === formatPercent(0) ? "<1%" : s;
};

// Slim summary meter: a tiny label + percentage + bar.
const meter = (labelKey: MessageKey, ariaKey: MessageKey, pctVal: number, color?: string) => (
<div className="cmeter">
<span className="cmeter-cap">
{t(labelKey)} <b>{pctText(pctVal)}</b>
{t(labelKey)} <b>{formatPercentFloor(pctVal)}</b>
</span>
<Bar value={pctVal} label={t(ariaKey, { name: c.name })} color={color} />
</div>
Expand All @@ -180,7 +174,7 @@ function CountryRow({
<>
<div className="metric-label">
<span>{t(labelKey)}</span>
<span className="muted">{t(detailKey, { pct: pctText(pctVal), visited, total })}</span>
<span className="muted">{t(detailKey, { pct: formatPercentFloor(pctVal), visited, total })}</span>
</div>
<Bar value={pctVal} label={t(ariaKey, { name: c.name })} color={color} />
</>
Expand Down Expand Up @@ -403,17 +397,11 @@ export function StatsView() {
);
/* eslint-enable react-hooks/exhaustive-deps */

// World-coverage %, with a floor so a real visit that rounds to 0 still reads
// as progress rather than a discouraging "0%".
const worldPctText = formatPercent(coverage.worldPct);
const worldPctLabel =
coverage.worldPct > 0 && worldPctText === formatPercent(0) ? "<1%" : worldPctText;
// City coverage is a sliver of a huge denominator (every 15k+ town on Earth),
// so a real visit almost always rounds to 0% — floor it to "<1%" so progress
// never reads as nothing.
const cityPctText = formatPercent(coverage.cityPct);
const cityPctLabel =
coverage.cityPct > 0 && cityPctText === formatPercent(0) ? "<1%" : cityPctText;
// Coverage %s, floored so a real visit that rounds to 0 still reads as progress
// ("<1%") rather than a discouraging "0%" (city coverage sits over a huge
// denominator — every 15k+ town on Earth — so it almost always rounds to 0).
const worldPctLabel = formatPercentFloor(coverage.worldPct);
const cityPctLabel = formatPercentFloor(coverage.cityPct);

// Continent constellation: a dot per continent, lit when it's been touched.
// Antarctica only earns a dot once visited (nobody's "missing" Antarctica).
Expand Down
4 changes: 2 additions & 2 deletions apps/postcards/src/features/travel/TripComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { MyPlacesPicker } from "./MyPlacesPicker";
import { myPlaces } from "./myPlaces";
import { addStop, moveStop, removeStop } from "./tripStops";
import { tripPathKm } from "./distance";
import { MODE_ORDER } from "./modes";
import { parseTripDate } from "./tripDate";

const MODES: TravelMode[] = ["flight", "train", "bus", "ferry", "car", "other"];
const MONTHS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];

/**
Expand Down Expand Up @@ -178,7 +178,7 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo
<label className="field">
<span className="field-label">{t("trip.compose.modeLabel")}</span>
<select className="select" value={mode} onChange={(e) => setMode(e.target.value as TravelMode)}>
{MODES.map((m) => (
{MODE_ORDER.map((m) => (
<option key={m} value={m}>
{t(`travel.mode.${m}` as const)}
</option>
Expand Down
7 changes: 7 additions & 0 deletions apps/postcards/src/lib/format/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export function formatPercent(value: number, locale = activeLocale, digits = 0):
}).format(value);
}

/** Like formatPercent, but a real but tiny share (rounds to 0%) floors to "<1%" so
* progress never reads as "nothing" (coverage %s over huge denominators). */
export function formatPercentFloor(value: number, locale = activeLocale): string {
const s = formatPercent(value, locale);
return value > 0 && s === formatPercent(0, locale) ? "<1%" : s;
}

/** ISO YYYY-MM-DD -> localized date; passthrough if unparseable. */
export function formatDate(iso: string | null, locale = activeLocale): string {
if (!iso) return "";
Expand Down
2 changes: 1 addition & 1 deletion apps/postcards/src/lib/image/photoBlobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface PhotoBlobKV {
}

/** A photo as persisted on the visit record: the id of its blob + its caption. */
export interface PhotoRef {
interface PhotoRef {
id: string;
caption: string | null;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/postcards/src/lib/schema/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ export function normalizeVisitPhotos(v: Visit): Visit {
export function backfillUpdatedAt<T extends { addedAt: string; updatedAt?: string }>(r: T): T {
return r.updatedAt ? r : { ...r, updatedAt: r.addedAt };
}

/** Current instant as an ISO-8601 string — the `updatedAt`/tombstone timestamp every
* store stamps on a write (one definition instead of a copy per store). */
export const stampNow = (): string => new Date().toISOString();
3 changes: 1 addition & 2 deletions apps/postcards/src/lib/store/useStories.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { create } from "zustand";
import { backfillUpdatedAt } from "../schema/helpers";
import { backfillUpdatedAt, stampNow } from "../schema/helpers";
import type { Photo, PlaceRef, Story } from "../schema/models";
import * as db from "../db/storiesDb";
import * as visitsDb from "../db/visitsDb";
import { stampPlaceCoords } from "../reference/placeCoords";
import { uuid } from "./uuid";

/** Now, as the ISO stamp written to `updatedAt` on every mutating path (spec 013). */
const stampNow = () => new Date().toISOString();

/** Journal order: newest story date first (ties broken by newest addedAt). */
export function sortStories(stories: Story[]): Story[] {
Expand Down
3 changes: 1 addition & 2 deletions apps/postcards/src/lib/store/useTrips.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { create } from "zustand";
import { backfillUpdatedAt } from "../schema/helpers";
import { backfillUpdatedAt, stampNow } from "../schema/helpers";
import type { PlaceRef, TravelMode, Trip } from "../schema/models";
import * as db from "../db/tripsDb";
import * as visitsDb from "../db/visitsDb";
import { uuid } from "./uuid";

/** Now, as the ISO stamp written to `updatedAt` on every mutating path (spec 013). */
const stampNow = () => new Date().toISOString();

interface TripsState {
trips: Trip[];
Expand Down
3 changes: 1 addition & 2 deletions apps/postcards/src/lib/store/useVisits.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { create } from "zustand";
import { backfillUpdatedAt, MAX_PHOTOS_PER_VISIT, normalizeVisitPhotos, placeKey } from "../schema/helpers";
import { backfillUpdatedAt, MAX_PHOTOS_PER_VISIT, normalizeVisitPhotos, placeKey, stampNow } from "../schema/helpers";
import type { Photo, PlaceRef, Visit } from "../schema/models";
import { sanitizeText } from "../schema/sanitize";
import * as db from "../db/visitsDb";
import { stampPlaceCoords } from "../reference/placeCoords";
import { uuid } from "./uuid";

/** Now, as the ISO stamp written to `updatedAt` on every mutating path (spec 013). */
const stampNow = () => new Date().toISOString();

/**
* Pure dedupe/upsert: at most one visit per (kind, id) (FR-015).
Expand Down
Loading