Files
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00

167 lines
5.8 KiB
TypeScript

import { vi } from 'vitest';
import type { AtlasController } from '../../src/mobile/screens/atlas/atlasController';
import type { VisitStatus } from '@trek/shared';
import type { AtlasData, BucketItem, CountryDetail } from '../../src/pages/atlas/atlasModel';
/**
* Fixtures for the atlas surfaces. Desktop page, mobile screen, bucket sheet
* and country popup all consume the same useAtlas() controller, so tests build
* one object here instead of mounting the hook with a Leaflet map behind it.
*
* The state setters are spies that also write the new value back onto the
* controller (including updater callbacks), so a test can assert both "the
* setter was called" and "this is the state it produced".
*/
export type VisitedRegions = Record<
string,
{ code: string; name: string; placeCount: number; manuallyMarked?: boolean; status?: VisitStatus }[]
>;
const echoT = (key: string, params?: Record<string, string | number>): string =>
params ? `${key}:${Object.values(params).join(',')}` : key;
export function buildAtlasData(over: Partial<AtlasData> = {}): AtlasData {
return {
countries: [{ code: 'FR', tripCount: 2, placeCount: 5, firstVisit: '2023-01-01', lastVisit: '2024-06-01', status: 'visited' }],
stats: { totalTrips: 3, totalPlaces: 10, totalCountries: 1, totalDays: 14, totalCities: 3, totalCountriesPlanned: 0, totalCountriesIdea: 0 },
mostVisited: null,
continents: { Europe: 1 },
continentsPlanned: {},
lastTrip: { id: 7, title: 'Paris Trip', countryCode: 'FR' },
nextTrip: null,
streak: 2,
firstYear: 2022,
tripsThisYear: 1,
...over,
};
}
export function buildBucketItem(over: Partial<BucketItem> = {}): BucketItem {
return {
id: 1,
name: 'Kyoto',
lat: null,
lng: null,
country_code: 'JP',
notes: null,
target_date: null,
...over,
};
}
export function buildCountryDetail(over: Partial<CountryDetail> = {}): CountryDetail {
return {
places: [],
trips: [],
manually_marked: false,
status: 'visited',
...over,
};
}
/** state field → its setter on the controller. */
const SETTERS: Record<string, string> = {
data: 'setData',
visitedRegions: 'setVisitedRegions',
bucketList: 'setBucketList',
confirmAction: 'setConfirmAction',
bucketForm: 'setBucketForm',
bucketSearch: 'setBucketSearch',
bucketSearchResults: 'setBucketSearchResults',
showBucketAdd: 'setShowBucketAdd',
bucketTab: 'setBucketTab',
bucketMonth: 'setBucketMonth',
bucketYear: 'setBucketYear',
bucketPoiMonth: 'setBucketPoiMonth',
bucketPoiYear: 'setBucketPoiYear',
atlas_country_search: 'set_atlas_country_search',
atlas_country_results: 'set_atlas_country_results',
atlas_country_open: 'set_atlas_country_open',
};
const COUNTRY_NAMES: Record<string, string> = {
AR: 'Argentina', DE: 'Germany', ES: 'Spain', FR: 'France', GR: 'Greece',
IT: 'Italy', JP: 'Japan', PE: 'Peru', PT: 'Portugal', US: 'United States',
};
export function buildAtlasController(over: Record<string, unknown> = {}): AtlasController {
const base: Record<string, unknown> = {
t: echoT,
language: 'en',
navigate: vi.fn(() => undefined),
resolveName: (code: string) => COUNTRY_NAMES[code] ?? code,
dark: false,
loading: false,
mapRef: { current: null },
regionTooltipRef: { current: null },
panelRef: { current: null },
glareRef: { current: null },
borderGlareRef: { current: null },
handlePanelMouseMove: vi.fn(() => undefined),
handlePanelMouseLeave: vi.fn(() => undefined),
data: buildAtlasData(),
stats: buildAtlasData().stats,
countries: buildAtlasData().countries,
visitedCountries: buildAtlasData().countries,
visibleCountries: buildAtlasData().countries,
showPlanned: false,
togglePlanned: vi.fn(() => undefined),
selectedCountry: null,
countryDetail: null,
loadCountryDetail: vi.fn(async () => undefined),
handleUnmarkCountry: vi.fn(() => undefined),
select_country_from_search: vi.fn(() => undefined),
visitedRegions: {} as VisitedRegions,
atlas_country_search: '',
atlas_country_results: [],
atlas_country_open: false,
atlas_country_options: [],
confirmAction: null,
executeConfirmAction: vi.fn(async () => undefined),
bucketMonth: 0,
bucketYear: 0,
bucketList: [] as BucketItem[],
bucketTab: 'stats',
showBucketAdd: false,
bucketForm: { name: '', notes: '', lat: '', lng: '', target_date: '' },
handleAddBucketItem: vi.fn(async () => undefined),
handleDeleteBucketItem: vi.fn(async () => undefined),
handleBucketPoiSearch: vi.fn(async () => undefined),
handleSelectBucketPoi: vi.fn(() => undefined),
bucketSearchResults: [],
bucketPoiMonth: 0,
bucketPoiYear: 0,
bucketSearching: false,
bucketSearch: '',
};
const ctrl: Record<string, unknown> = { ...base, ...over };
for (const [field, setter] of Object.entries(SETTERS)) {
if (setter in over) continue;
ctrl[setter] = vi.fn((next: unknown) => {
ctrl[field] = typeof next === 'function' ? (next as (prev: unknown) => unknown)(ctrl[field]) : next;
});
}
// stats/countries mirror data unless a test pinned them explicitly.
const data = ctrl.data as AtlasData | null;
if (!('stats' in over)) ctrl.stats = data?.stats ?? { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 };
if (!('countries' in over)) ctrl.countries = data?.countries ?? [];
// The hook derives these from data + showPlanned; mirror that so a test that only
// sets `data` still gets a consistent controller.
const all = (ctrl.countries ?? []) as AtlasData['countries'];
if (!('visitedCountries' in over)) ctrl.visitedCountries = all.filter(c => (c.status ?? 'visited') === 'visited');
if (!('visibleCountries' in over)) {
ctrl.visibleCountries = ctrl.showPlanned ? all : all.filter(c => (c.status ?? 'visited') === 'visited');
}
return ctrl as unknown as AtlasController;
}