feat(assistant): passport, country and bucket list cards

This commit is contained in:
Maurice
2026-10-22 15:50:54 +02:00
parent fd15a7343e
commit da7af8337d
57 changed files with 4564 additions and 72 deletions
@@ -0,0 +1,184 @@
import type { AnswerInput } from '../card-types';
import { clipped, finite, recordOf, type Row } from '../card-values';
import { countOf, countryCodeOf, entryOf, isoDayOf, statusOf } from './atlas-values';
import {
ATLAS_CARD_CONTINENTS,
type AtlasBucketCard,
type AtlasBucketEntry,
type AtlasCardContinent,
type AtlasCountryCard,
type AtlasPassportCard,
type VisitStatus,
} from '@trek/shared';
/**
* The answer cards of the atlas reads: the passport of `get_atlas_stats`, one
* country of `get_country_atlas_places`, the wishes of `list_bucket_list`.
* Built from the raw answer: every code checked, every list cut to its bound
* with the rest counted, every text masked. Never a coordinate.
*/
const MAX_FLAGS = 40;
const MAX_TRIPS = 4;
const MAX_PLACES = 8;
const MAX_WISHES = 12;
function rowsOf(value: unknown): Row[] {
return Array.isArray(value) ? value.map(recordOf) : [];
}
/** Later dates first; rows without one last. */
function byDateDesc(a: string | null, b: string | null): number {
if (a === b) return 0;
if (a === null) return 1;
if (b === null) return -1;
return a < b ? 1 : -1;
}
/** Earlier dates first; rows without one last. */
function byDateAsc(a: string | null, b: string | null): number {
if (a === b) return 0;
if (a === null) return 1;
if (b === null) return -1;
return a < b ? -1 : 1;
}
/** The flags in the Atlas page's order: visited ones latest first, then planned ones soonest first. */
function flagsOf(countries: Row[]) {
const listed = countries.flatMap((row) => {
const code = countryCodeOf(row.code);
// A user without trips has only countries marked by hand, which are visited.
const status: VisitStatus = statusOf(row.status) ?? 'visited';
return code && status !== 'idea'
? [{ code, status, first: isoDayOf(row.firstVisit), last: isoDayOf(row.lastVisit) }]
: [];
});
const visited = listed.filter((f) => f.status === 'visited').sort((a, b) => byDateDesc(a.last, b.last));
const planned = listed.filter((f) => f.status === 'planned').sort((a, b) => byDateAsc(a.first, b.first));
return [...visited, ...planned].map(({ code, status }) => ({ code, status }));
}
function isContinent(name: string): name is AtlasCardContinent {
return (ATLAS_CARD_CONTINENTS as readonly string[]).includes(name);
}
function continentsOf(visited: Row, planned: Row): AtlasPassportCard['continents'] {
const names = [...new Set([...Object.keys(visited), ...Object.keys(planned)])].filter(isContinent);
return names
.map((name) => ({ name, visited: countOf(visited[name]), planned: countOf(planned[name]) }))
.sort((a, b) => b.visited - a.visited || b.planned - a.planned)
.slice(0, ATLAS_CARD_CONTINENTS.length);
}
function nextTripOf(value: unknown): AtlasPassportCard['nextTrip'] {
const trip = recordOf(value);
const title = clipped(trip.title, 80);
if (!title) return null;
const days = finite(trip.daysUntil);
const daysUntil = days !== null && Number.isInteger(days) && days >= 0 && days <= 36_500 ? days : null;
return { title, startDate: isoDayOf(trip.start_date), daysUntil };
}
/** `{ stats, travel }` of get_atlas_stats as the passport; null when it holds no atlas. */
export function passportOf(raw: unknown): AtlasPassportCard | null {
const stats = recordOf(recordOf(raw).stats);
if (!Array.isArray(stats.countries)) return null;
const travel = recordOf(recordOf(raw).travel);
const totals = recordOf(stats.stats);
const flags = flagsOf(rowsOf(stats.countries));
const flown = finite(travel.totalDistanceKm);
const streak = finite(stats.streak);
return {
kind: 'atlasPassport',
countries: {
visited: countOf(totals.totalCountries),
planned: countOf(totals.totalCountriesPlanned),
idea: countOf(totals.totalCountriesIdea),
},
totals: {
trips: countOf(totals.totalTrips),
days: countOf(totals.totalDays),
places: countOf(totals.totalPlaces),
// The Atlas page's own figure, the page "Open Atlas" leads to; the passport's list without it.
cities: totals.totalCities === undefined ? rowsOf(travel.cities).length : countOf(totals.totalCities),
flownKm: flown !== null && flown >= 1 ? countOf(flown) : null,
},
flags: flags.slice(0, MAX_FLAGS),
moreFlags: Math.max(0, flags.length - MAX_FLAGS),
continents: continentsOf(recordOf(stats.continents), recordOf(stats.continentsPlanned)),
nextTrip: nextTripOf(stats.nextTrip),
streakYears: streak !== null && Number.isInteger(streak) && streak >= 0 && streak <= 200 ? streak : null,
};
}
/** One country of get_country_atlas_places: its trips latest first, its places in the order of those trips. */
export function countryOf(args: Row, raw: unknown): AtlasCountryCard | null {
const code = countryCodeOf(args.countryCode);
if (!code) return null;
const r = recordOf(raw);
const places = rowsOf(r.places);
const tripRows = rowsOf(r.trips).sort((a, b) => byDateDesc(isoDayOf(a.start_date), isoDayOf(b.start_date)));
const trips = tripRows.flatMap((trip) => {
const id = finite(trip.id);
const title = clipped(trip.title, 80);
return id !== null && Number.isInteger(id) && id > 0 && title
? [{ id, title, startDate: isoDayOf(trip.start_date), endDate: isoDayOf(trip.end_date) }]
: [];
});
const order = new Map(tripRows.map((trip, index): [unknown, number] => [trip.id, index]));
const names = [
...new Set(
places
.map((place, index) => ({ place, index, trip: order.get(place.trip_id) ?? tripRows.length }))
.sort((a, b) => a.trip - b.trip || a.index - b.index)
.map(({ place }) => clipped(place.name, 80))
.filter((name): name is string => name !== null),
),
];
const marked = r.manually_marked === true;
const source = r.marked_source;
// A country with no trip, no place and no mark of its own is not on the map, whatever the read calls it.
const onMap = marked || places.length > 0 || trips.length > 0;
return {
kind: 'atlasCountry',
code,
status: onMap ? statusOf(r.status) : null,
markedBy: source === 'manual' || source === 'dawarich' || source === 'region' ? source : null,
placeCount: places.length,
tripCount: trips.length,
trips: trips.slice(0, MAX_TRIPS),
moreTrips: Math.max(0, trips.length - MAX_TRIPS),
places: names.slice(0, MAX_PLACES),
morePlaces: Math.max(0, names.length - MAX_PLACES),
change: null,
};
}
/** The open wishes first, soonest first and undated last, then the ones that came true, latest first. */
function wishOrder(a: AtlasBucketEntry, b: AtlasBucketEntry): number {
if (!a.reachedOn !== !b.reachedOn) return a.reachedOn ? 1 : -1;
return a.reachedOn ? byDateDesc(a.reachedOn, b.reachedOn) : byDateAsc(a.targetDate, b.targetDate);
}
/** The wishes of list_bucket_list; null for an empty list. */
export function bucketListOf(raw: unknown): AtlasBucketCard | null {
const entries = rowsOf(recordOf(raw).items)
.map((row) => entryOf(row, { fromResult: true }))
.filter((entry): entry is AtlasBucketEntry => entry !== null)
.sort(wishOrder);
const [first, ...rest] = entries;
if (!first) return null;
return {
kind: 'atlasBucket',
items: [first, ...rest.slice(0, MAX_WISHES - 1)],
more: Math.max(0, entries.length - MAX_WISHES),
change: null,
};
}
/** The answer builders by tool, for the registry. */
export const ATLAS_ANSWERS = {
get_atlas_stats: (input: AnswerInput) => passportOf(input.value),
get_country_atlas_places: (input: AnswerInput) => countryOf(input.args, input.value),
list_bucket_list: (input: AnswerInput) => bucketListOf(input.value),
};
@@ -0,0 +1,109 @@
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { idIn, readKey, recordOf } from '../card-values';
import { countOf, entryOf, statusOf } from './atlas-values';
import type { AssistantChangePreview, AtlasBucketCard, AtlasCountryCard, AtlasStatusAfter } from '@trek/shared';
/**
* The cards of applied atlas changes, which take the place of the change
* cards beside their receipts: the country with where it really stands now
* and the exact counters (the mark tools answer `after`), a wish with the id
* it was stored under. And the way back from an added wish: deleting it,
* as long as it still reads as it was added.
*/
type AppliedInput = { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null };
function countryCardOf(preview: AssistantChangePreview | null): AtlasCountryCard | null {
const card = preview?.card;
return card?.kind === 'atlasCountry' ? card : null;
}
function bucketCardOf(preview: AssistantChangePreview | null): AtlasBucketCard | null {
const card = preview?.card;
return card?.kind === 'atlasBucket' ? card : null;
}
/** A country change with the counters the write answered; the preview's prediction where it answered none. */
function appliedCountry({ result, preview }: AppliedInput, region: 'mark' | 'unmark' | null): AtlasCountryCard | null {
const card = countryCardOf(preview);
const change = card?.change;
if (!card || !change) return null;
const after = recordOf(recordOf(result).after);
if (Object.keys(after).length === 0) return card;
const status: AtlasStatusAfter = statusOf(after.country_status) ?? 'none';
const visited = countOf(after.countries_visited);
const regionsLeft = after.regions_visited === undefined ? null : countOf(after.regions_visited);
const regionAfter: AtlasStatusAfter = region === 'mark' ? 'visited' : 'none';
return {
...card,
change: {
...change,
after: status,
countries: change.countries ? { before: change.countries.before, after: visited } : null,
region: change.region && region ? { ...change.region, after: regionAfter } : change.region,
regions:
change.regions && regionsLeft !== null ? { before: change.regions.before, after: regionsLeft } : change.regions,
},
};
}
/** An added or changed wish as it was stored, with its id; the counter from the preview. */
function appliedWish({ result, preview }: AppliedInput, op: 'add' | 'update'): AtlasBucketCard | null {
const entry = entryOf(recordOf(result).item, { fromResult: true });
if (!entry) return null;
const change = bucketCardOf(preview)?.change ?? null;
const wishes = change?.wishes ?? null;
return {
kind: 'atlasBucket',
items: [entry],
more: 0,
change: {
op,
before: op === 'update' ? (change?.before ?? null) : null,
duplicate: false,
// The add landed, so the list grew by one whatever the preview foresaw.
wishes: wishes && op === 'add' ? { before: wishes.before, after: wishes.before + 1 } : wishes,
},
};
}
export const ATLAS_APPLIED = {
mark_country_visited: (input: AppliedInput) => appliedCountry(input, null),
unmark_country_visited: (input: AppliedInput) => appliedCountry(input, null),
mark_region_visited: (input: AppliedInput) => appliedCountry(input, 'mark'),
unmark_region_visited: (input: AppliedInput) => appliedCountry(input, 'unmark'),
create_bucket_list_item: (input: AppliedInput) => appliedWish(input, 'add'),
update_bucket_list_item: (input: AppliedInput) => appliedWish(input, 'update'),
// A removed wish reads as the change card showed it.
delete_bucket_list_item: ({ preview }: AppliedInput) => bucketCardOf(preview),
};
const BUCKET_READ = { tool: 'list_bucket_list', args: {} };
/**
* An added wish, still as it was added: on the list under its id with the
* name, country, region, date and notes it was created with, and not reached
* since. A wish edited or ticked off meanwhile is kept.
*/
function wishUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const added = recordOf(recordOf(result).item);
const id = idIn(result, 'item');
const items = recordOf(snapshot.reads.get(readKey(BUCKET_READ.tool, BUCKET_READ.args))).items;
if (id === null || !Array.isArray(items)) return false;
const current = items.map(recordOf).find((row) => row.id === id);
if (!current || current.visited_at) return false;
return (['name', 'country_code', 'region_code', 'target_date', 'notes', 'lat', 'lng'] as const).every(
(key) => (current[key] ?? null) === (added[key] ?? null),
);
}
/** An added wish taken back by deleting it. */
export const DELETE_ADDED_WISH: NonNullable<ToolCards['undo']> = {
tool: 'delete_bucket_list_item',
args: (_args, result) => {
const itemId = idIn(result, 'item');
return itemId ? { itemId } : null;
},
reads: () => [BUCKET_READ],
unchanged: wishUnchanged,
};
@@ -0,0 +1,315 @@
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, finite } from '../card-values';
import { localeOf, Rows, text } from '../preview-format';
import {
argText,
atlasSnapshotOf,
bucketSnapshotOf,
countryCodeOf,
entryOf,
identityOf,
regionCodeOf,
regionsSnapshotOf,
sameWish,
targetDateOf,
visitedRegionsIn,
type AtlasSnapshot,
type BucketSnapshot,
type CountryStanding,
type RegionEntry,
} from './atlas-values';
import type {
AtlasBucketCard,
AtlasBucketEntry,
AtlasCountryCard,
AtlasStatusAfter,
AtlasTally,
TrekCard,
VisitStatus,
} from '@trek/shared';
/**
* The change cards of the atlas writes, drawn before the user decides: the
* country (or region) with its flag, where it stands now and after, and how
* the visited counters move; a wish of the bucket list as it will be added,
* changed or removed, and how long the list gets. A mark or a bucket change is
* predicted exactly; an unmark of a country the user's trips put on the map,
* and the unmark of a country's last region, stay `unknown` until applied,
* since the trips decide (the applied card is exact). The subject names the
* country in the user's language instead of its code.
*/
/** What a change card is predicted from, read once per turn; null where a read failed. */
export interface AtlasLoaded {
atlas: AtlasSnapshot | null;
regions: ReadonlyMap<string, RegionEntry> | null;
bucket: BucketSnapshot | null;
}
const NOTHING: AtlasLoaded = { atlas: null, regions: null, bucket: null };
export async function loadAtlas(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
return { ...NOTHING, atlas: atlasSnapshotOf(await read('get_atlas_stats')) };
}
export async function loadAtlasRegions(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
const [atlas, regions] = await Promise.all([read('get_atlas_stats'), read('list_visited_regions')]);
return { ...NOTHING, atlas: atlasSnapshotOf(atlas), regions: regionsSnapshotOf(regions) };
}
export async function loadBucket(_args: ToolArgs, read: CardReader): Promise<AtlasLoaded> {
return { ...NOTHING, bucket: bucketSnapshotOf(await read('list_bucket_list')) };
}
/** A country's name in the user's language; its code where the runtime has none. */
export function countryNameIn(ctx: Pick<PreviewContext, 'locale'>, code: string): string {
try {
return new Intl.DisplayNames([localeOf(ctx)], { type: 'region' }).of(code) ?? code;
} catch {
return code;
}
}
/** The empty country a change card starts from: no trips or places listed, the change carries the story. */
function countryCard(code: string, entry: CountryStanding | undefined) {
return {
kind: 'atlasCountry' as const,
code,
status: entry?.status ?? null,
markedBy: null,
placeCount: entry?.placeCount ?? null,
tripCount: entry?.tripCount ?? null,
trips: [],
moreTrips: 0,
places: [],
morePlaces: 0,
};
}
function tally(before: number, after: number | null): AtlasTally {
return { before, after: after === null ? null : Math.max(0, after) };
}
function markCountry(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
const code = countryCodeOf(args.country_code);
if (!code) return null;
const atlas = loaded?.atlas ?? null;
const entry = atlas?.countries.get(code);
const countries = atlas ? tally(atlas.visited, atlas.visited + (entry?.status === 'visited' ? 0 : 1)) : null;
return {
...countryCard(code, entry),
change: { op: 'mark', region: null, after: 'visited', countries, regions: null },
};
}
/**
* Where an unmarked country ends up. The tombstone hides what a mark or a
* booking put on the map; a country with places of the user's trips stays,
* at whatever status those trips give it.
*/
function unmarkCountry(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
const code = countryCodeOf(args.country_code);
if (!code) return null;
const atlas = loaded?.atlas ?? null;
const entry = atlas?.countries.get(code);
let after: AtlasStatusAfter = 'none';
let visitedAfter: number | null = atlas?.visited ?? null;
if (entry && entry.placeCount > 0) {
after = entry.status === 'visited' ? 'unknown' : entry.status;
if (entry.status === 'visited') visitedAfter = null;
} else if (entry?.status === 'visited' && atlas) visitedAfter = atlas.visited - 1;
return {
...countryCard(code, entry),
change: {
op: 'unmark',
region: null,
after,
countries: atlas ? tally(atlas.visited, visitedAfter) : null,
regions: null,
},
};
}
function markRegion(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
const code = countryCodeOf(args.countryCode);
const regionCode = regionCodeOf(args.regionCode);
if (!code || !regionCode) return null;
const country = markCountry({ country_code: code }, loaded);
const regions = loaded?.regions ?? null;
const before: VisitStatus | null = regions?.get(regionCode)?.status ?? null;
const visited = regions ? visitedRegionsIn(regions, code) : null;
if (!country?.change) return null;
return {
...country,
change: {
...country.change,
region: { code: regionCode, name: argText(args.regionName, 80) ?? regionCode, before, after: 'visited' },
regions: visited === null ? null : tally(visited, visited + (before === 'visited' ? 0 : 1)),
},
};
}
/**
* The unmark of a region. Its country is read from the regions, else from the
* code. Taking a country's last region away may take the country along
* (unless it was marked itself), so the country's fate stays `unknown` then.
*/
function unmarkRegion(args: ToolArgs, loaded: AtlasLoaded | null): AtlasCountryCard | null {
const regionCode = regionCodeOf(args.regionCode);
if (!regionCode) return null;
const regions = loaded?.regions ?? null;
const known = regions?.get(regionCode);
const code = known?.country ?? regionCode.slice(0, 2);
const atlas = loaded?.atlas ?? null;
const entry = atlas?.countries.get(code);
const visited = regions ? visitedRegionsIn(regions, code) : null;
const left = visited === null ? null : visited - (known?.status === 'visited' ? 1 : 0);
const cascades = left === null || left <= 0;
const after: AtlasStatusAfter = cascades ? 'unknown' : (entry?.status ?? 'none');
const countries = atlas ? tally(atlas.visited, cascades ? null : atlas.visited) : null;
return {
...countryCard(code, entry),
change: {
op: 'unmark',
region: { code: regionCode, name: known?.name ?? regionCode, before: known?.status ?? null, after: 'none' },
after,
countries,
regions: visited === null ? null : tally(visited, left),
},
};
}
function bucketCard(entry: AtlasBucketEntry, change: NonNullable<AtlasBucketCard['change']>): AtlasBucketCard {
return { kind: 'atlasBucket', items: [entry], more: 0, change };
}
function createWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
const entry = entryOf({ ...args, id: null, visited_at: null }, { fromResult: false });
if (!entry) return null;
const bucket = loaded?.bucket ?? null;
const identity = identityOf(args, { upperCountry: true });
const duplicate = bucket ? [...bucket.wishes.values()].some((wish) => sameWish(wish.identity, identity)) : false;
const wishes = bucket ? tally(bucket.size, bucket.size + (duplicate ? 0 : 1)) : null;
return bucketCard(entry, { op: 'add', before: null, duplicate, wishes });
}
/** The wish as it will read once changed: only the fields the call sets move. */
function changedWish(args: ToolArgs, before: AtlasBucketEntry): AtlasBucketEntry {
const countryCode = args.country_code !== undefined ? countryCodeOf(args.country_code) : before.countryCode;
const region = before.regionCode;
return {
...before,
name: argText(args.name, 120) ?? before.name,
notes: args.notes !== undefined ? argText(args.notes, 200) : before.notes,
countryCode,
regionCode: region && countryCode && region.startsWith(`${countryCode}-`) ? region : null,
targetDate: args.target_date !== undefined ? targetDateOf(args.target_date) : before.targetDate,
};
}
function updateWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
const id = argId(args, 'itemId');
const bucket = loaded?.bucket ?? null;
const current = id === null ? undefined : bucket?.wishes.get(id);
if (!bucket || !current) return null;
// A blank name leaves the stored one, as the service's `name?.trim() || item.name` does.
const named = typeof args.name === 'string' && args.name.trim() !== '' ? args.name : null;
const next = identityOf({
name: named ?? current.identity.name,
country_code: args.country_code !== undefined ? args.country_code : current.identity.country,
target_date: args.target_date !== undefined ? args.target_date : current.identity.target,
lat: args.lat !== undefined ? finite(args.lat) : current.identity.lat,
lng: args.lng !== undefined ? finite(args.lng) : current.identity.lng,
});
const duplicate = [...bucket.wishes].some(([other, wish]) => other !== id && sameWish(wish.identity, next));
return bucketCard(changedWish(args, current.entry), { op: 'update', before: current.entry, duplicate, wishes: null });
}
function deleteWish(args: ToolArgs, loaded: AtlasLoaded | null): AtlasBucketCard | null {
const id = argId(args, 'itemId');
const bucket = loaded?.bucket ?? null;
const current = id === null ? undefined : bucket?.wishes.get(id);
if (!bucket || !current) return null;
return bucketCard(current.entry, {
op: 'remove',
before: current.entry,
duplicate: false,
wishes: tally(bucket.size, bucket.size - 1),
});
}
type ChangeBuilder = (ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null) => TrekCard | null;
/** The change cards by tool. */
export const ATLAS_CHANGES = {
mark_country_visited: (_ctx, args, loaded) => markCountry(args, loaded),
unmark_country_visited: (_ctx, args, loaded) => unmarkCountry(args, loaded),
mark_region_visited: (_ctx, args, loaded) => markRegion(args, loaded),
unmark_region_visited: (_ctx, args, loaded) => unmarkRegion(args, loaded),
create_bucket_list_item: (_ctx, args, loaded) => createWish(args, loaded),
update_bucket_list_item: (_ctx, args, loaded) => updateWish(args, loaded),
delete_bucket_list_item: (_ctx, args, loaded) => deleteWish(args, loaded),
} satisfies Readonly<Record<string, ChangeBuilder>>;
/** The words of a country change: the country by its name, under the tool's own label. */
function describeCountry(tool: string, ctx: PreviewContext, args: ToolArgs): Described {
const code = countryCodeOf(args.country_code);
return {
subject: code ? countryNameIn(ctx, code) : text(args.country_code),
rows: new Rows(),
dayId: null,
placeIds: [],
tool,
domain: 'atlas',
};
}
function describeRegion(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null): Described {
const regionCode = regionCodeOf(args.regionCode);
const known = regionCode ? loaded?.regions?.get(regionCode) : undefined;
const country = countryCodeOf(args.countryCode) ?? known?.country ?? (regionCode ? regionCode.slice(0, 2) : null);
const name = argText(args.regionName, 80) ?? known?.name ?? regionCode ?? text(args.regionCode);
return {
subject: name,
rows: new Rows().add('location', country ? countryNameIn(ctx, country) : ''),
dayId: null,
placeIds: [],
tool,
domain: 'atlas',
};
}
function describeWish(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null): Described {
const id = argId(args, 'itemId');
const before = id === null ? undefined : loaded?.bucket?.wishes.get(id)?.entry;
const country = countryCodeOf(args.country_code);
const rows = new Rows();
if (tool === 'update_bucket_list_item') {
if (typeof args.name === 'string') rows.change('name', before?.name ?? null, text(args.name));
if (args.notes !== undefined) rows.change('notes', before?.notes ?? null, text(args.notes));
} else if (tool === 'create_bucket_list_item') {
rows.add('location', country ? countryNameIn(ctx, country) : '').add('notes', text(args.notes));
}
const named = tool === 'delete_bucket_list_item' ? null : argText(args.name, 120);
return {
subject: named ?? before?.name ?? (id === null ? '' : `#${id}`),
rows,
dayId: null,
placeIds: [],
tool,
domain: 'atlas',
};
}
type Describer = (ctx: PreviewContext, args: ToolArgs, loaded: AtlasLoaded | null) => Described;
/** The describers by tool; each one gives a card for empty arguments too. */
export const ATLAS_DESCRIBERS = {
mark_country_visited: (ctx, args) => describeCountry('mark_country_visited', ctx, args),
unmark_country_visited: (ctx, args) => describeCountry('unmark_country_visited', ctx, args),
mark_region_visited: (ctx, args, loaded) => describeRegion('mark_region_visited', ctx, args, loaded),
unmark_region_visited: (ctx, args, loaded) => describeRegion('unmark_region_visited', ctx, args, loaded),
create_bucket_list_item: (ctx, args, loaded) => describeWish('create_bucket_list_item', ctx, args, loaded),
update_bucket_list_item: (ctx, args, loaded) => describeWish('update_bucket_list_item', ctx, args, loaded),
delete_bucket_list_item: (ctx, args, loaded) => describeWish('delete_bucket_list_item', ctx, args, loaded),
} satisfies Readonly<Record<string, Describer>>;
@@ -0,0 +1,201 @@
import { blockText, clipped, finite, recordOf } from '../card-values';
import type { AtlasBucketEntry, VisitStatus } from '@trek/shared';
/**
* The readers the atlas cards are built with: codes upper-cased and checked,
* counts kept whole and in bounds, a bucket-list row as the card shows it,
* and the snapshots a change card is predicted from. A wish's coordinates
* stay in the snapshot (they decide whether an add is a duplicate) and never
* reach a card.
*/
const COUNTRY = /^[A-Z]{2}$/;
const REGION = /^[A-Z]{2}-[A-Z0-9]{1,8}$/;
const ISO_DAY = /^\d{4}-\d{2}-\d{2}/;
const TARGET = /^\d{4}(-\d{2}(-\d{2})?)?$/;
const MAX_COUNT = 1_000_000;
/** A country code as a card holds it: ISO alpha-2 in upper case, else null. */
export function countryCodeOf(value: unknown): string | null {
const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
return COUNTRY.test(code) ? code : null;
}
/** A region code as a card holds it: ISO 3166-2 in upper case, else null. */
export function regionCodeOf(value: unknown): string | null {
const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
return REGION.test(code) ? code : null;
}
/** The day of a date or a timestamp as `YYYY-MM-DD`, else null. */
export function isoDayOf(value: unknown): string | null {
return typeof value === 'string' && ISO_DAY.test(value) ? value.slice(0, 10) : null;
}
/** A count as a card holds it: a whole number from 0 up to its bound; anything else counts 0. */
export function countOf(value: unknown): number {
const n = finite(value);
return n === null || n < 0 ? 0 : Math.min(Math.round(n), MAX_COUNT);
}
export function statusOf(value: unknown): VisitStatus | null {
return value === 'visited' || value === 'planned' || value === 'idea' ? value : null;
}
/** An argument's text as it will be stored: on one line and cut, not masked. */
export function argText(value: unknown, max: number): string | null {
return typeof value === 'string' ? blockText(value.replace(/\s+/g, ' '), max, { mask: false }) : null;
}
/** When a wish is planned for, a year, a month or a day; anything else is none. */
export function targetDateOf(value: unknown): string | null {
return typeof value === 'string' && TARGET.test(value) ? value : null;
}
function positiveId(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
/**
* A wish as a card shows it, from a bucket-list row (the tool's answer, its
* text masked) or from the arguments of an add (cut only). Null without a name.
*/
export function entryOf(row: unknown, { fromResult }: { fromResult: boolean }): AtlasBucketEntry | null {
const r = recordOf(row);
const text = (value: unknown, max: number) => (fromResult ? clipped(value, max) : argText(value, max));
const name = text(r.name, 120);
if (!name) return null;
const countryCode = countryCodeOf(r.country_code);
const region = regionCodeOf(r.region_code);
const reachedOn = isoDayOf(r.visited_at);
const source = r.visited_source;
return {
id: positiveId(r.id),
name,
countryCode,
// A region belongs to its own country, the rule the service keeps too.
regionCode: region && countryCode && region.startsWith(`${countryCode}-`) ? region : null,
targetDate: targetDateOf(r.target_date),
notes: text(r.notes, 200),
reachedOn,
reachedFrom: reachedOn && (source === 'manual' || source === 'dawarich') ? source : null,
};
}
/** Where one country stands on the atlas map. */
export interface CountryStanding {
status: VisitStatus;
placeCount: number;
tripCount: number;
}
/** The atlas as a change is predicted from: visited countries and each country's standing. */
export interface AtlasSnapshot {
visited: number;
countries: ReadonlyMap<string, CountryStanding>;
}
/** `get_atlas_stats` read as a snapshot; null when it is not an atlas. */
export function atlasSnapshotOf(raw: unknown): AtlasSnapshot | null {
const stats = recordOf(recordOf(raw).stats);
if (!Array.isArray(stats.countries)) return null;
const countries = new Map<string, CountryStanding>();
for (const row of stats.countries) {
const r = recordOf(row);
const code = countryCodeOf(r.code);
// A user without trips has only countries marked by hand, which carry no status: they are visited.
if (code) {
countries.set(code, {
status: statusOf(r.status) ?? 'visited',
placeCount: countOf(r.placeCount),
tripCount: countOf(r.tripCount),
});
}
}
return { visited: countOf(recordOf(stats.stats).totalCountries), countries };
}
/** One region of the atlas map. */
export interface RegionEntry {
name: string;
country: string;
status: VisitStatus;
}
/** `list_visited_regions` read as regions by code; null when it is not a region list. */
export function regionsSnapshotOf(raw: unknown): ReadonlyMap<string, RegionEntry> | null {
const grouped = recordOf(raw).regions;
if (!grouped || typeof grouped !== 'object') return null;
const regions = new Map<string, RegionEntry>();
for (const [country, list] of Object.entries(recordOf(grouped))) {
const countryCode = countryCodeOf(country);
if (!countryCode || !Array.isArray(list)) continue;
for (const row of list) {
const r = recordOf(row);
const code = regionCodeOf(r.code);
const status = statusOf(r.status);
if (code && status) regions.set(code, { name: clipped(r.name, 80) ?? code, country: countryCode, status });
}
}
return regions;
}
/** Visited regions of one country in a snapshot. */
export function visitedRegionsIn(regions: ReadonlyMap<string, RegionEntry>, country: string): number {
return [...regions.values()].filter((region) => region.country === country && region.status === 'visited').length;
}
/** What makes two wishes the same wish (#1898): the name, the country, the target date and the spot. */
interface WishIdentity {
name: string;
country: string | null;
target: string | null;
lat: number | null;
lng: number | null;
}
/** An empty value and none are the same "not set", as the service reads them (it does not trim). */
function blankToNull(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null;
}
/**
* A wish's identity from raw values, the way the service compares them: the
* name trimmed and folded to ASCII lower case (lower() in SQLite folds ASCII
* only), the country exactly as it will be stored (`country_code IS ?`
* compares it as is). Only create_bucket_list_item upper-cases its country
* (its zod schema does), so only an add asks for `upperCountry`.
*/
export function identityOf(row: unknown, { upperCountry = false }: { upperCountry?: boolean } = {}): WishIdentity {
const r = recordOf(row);
const country = blankToNull(r.country_code);
return {
name: (typeof r.name === 'string' ? r.name.trim() : '').replace(/[A-Z]/g, (c) => c.toLowerCase()),
country: country !== null && upperCountry ? country.toUpperCase() : country,
target: blankToNull(r.target_date),
lat: finite(r.lat),
lng: finite(r.lng),
};
}
export function sameWish(a: WishIdentity, b: WishIdentity): boolean {
return a.name === b.name && a.country === b.country && a.target === b.target && a.lat === b.lat && a.lng === b.lng;
}
/** The bucket list as a change is predicted from: how many wishes, each with its identity. */
export interface BucketSnapshot {
size: number;
wishes: ReadonlyMap<number, { entry: AtlasBucketEntry; identity: WishIdentity }>;
}
/** `list_bucket_list` read as a snapshot; null when it is not a bucket list. */
export function bucketSnapshotOf(raw: unknown): BucketSnapshot | null {
const items = recordOf(raw).items;
if (!Array.isArray(items)) return null;
const wishes = new Map<number, { entry: AtlasBucketEntry; identity: WishIdentity }>();
for (const row of items) {
const entry = entryOf(row, { fromResult: true });
if (entry?.id) wishes.set(entry.id, { entry, identity: identityOf(row) });
}
return { size: items.length, wishes };
}
+100 -3
View File
@@ -1,4 +1,101 @@
import type { DomainCards } from '../card-types';
import type { DomainCards, ToolCards } from '../card-types';
import { ATLAS_ANSWERS } from './atlas-answers';
import { ATLAS_APPLIED, DELETE_ADDED_WISH } from './atlas-applied';
import {
ATLAS_CHANGES,
ATLAS_DESCRIBERS,
loadAtlas,
loadAtlasRegions,
loadBucket,
type AtlasLoaded,
} from './atlas-changes';
/** The cards of the atlas: countries, regions and the bucket list. None yet: this package fills them in. */
export const ATLAS_CARDS: DomainCards = { id: 'atlas', tools: {} };
/**
* The atlas in the assistant chat: the passport of the user's travels, one
* country with its trips and places, the bucket list as wish tiles, and the
* changes to them drawn with their flag and the counters they move. Atlas
* rows belong to the user, not to the trip, so nothing here lands on the
* trip's map; a receipt opens the Atlas instead. Only an added wish can be
* taken back: an unmark hides a country and may take regions along, which no
* mark restores.
*/
const ATLAS_WRITE = { entity: 'atlas' } as const;
const markCountry: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
load: loadAtlas,
describe: ATLAS_DESCRIBERS.mark_country_visited,
change: ATLAS_CHANGES.mark_country_visited,
applied: ATLAS_APPLIED.mark_country_visited,
};
const unmarkCountry: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
load: loadAtlas,
describe: ATLAS_DESCRIBERS.unmark_country_visited,
change: ATLAS_CHANGES.unmark_country_visited,
applied: ATLAS_APPLIED.unmark_country_visited,
};
const markRegion: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
load: loadAtlasRegions,
describe: ATLAS_DESCRIBERS.mark_region_visited,
change: ATLAS_CHANGES.mark_region_visited,
applied: ATLAS_APPLIED.mark_region_visited,
};
const unmarkRegion: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
load: loadAtlasRegions,
describe: ATLAS_DESCRIBERS.unmark_region_visited,
change: ATLAS_CHANGES.unmark_region_visited,
applied: ATLAS_APPLIED.unmark_region_visited,
};
const createWish: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
load: loadBucket,
describe: ATLAS_DESCRIBERS.create_bucket_list_item,
change: ATLAS_CHANGES.create_bucket_list_item,
applied: ATLAS_APPLIED.create_bucket_list_item,
undo: DELETE_ADDED_WISH,
};
const updateWish: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericUpdate' },
load: loadBucket,
describe: ATLAS_DESCRIBERS.update_bucket_list_item,
change: ATLAS_CHANGES.update_bucket_list_item,
applied: ATLAS_APPLIED.update_bucket_list_item,
};
const deleteWish: ToolCards<AtlasLoaded> = {
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
load: loadBucket,
describe: ATLAS_DESCRIBERS.delete_bucket_list_item,
change: ATLAS_CHANGES.delete_bucket_list_item,
applied: ATLAS_APPLIED.delete_bucket_list_item,
};
export const ATLAS_CARDS: DomainCards = {
id: 'atlas',
tools: {
get_atlas_stats: { answer: ATLAS_ANSWERS.get_atlas_stats },
get_country_atlas_places: { answer: ATLAS_ANSWERS.get_country_atlas_places },
list_bucket_list: { answer: ATLAS_ANSWERS.list_bucket_list },
mark_country_visited: markCountry,
unmark_country_visited: unmarkCountry,
mark_region_visited: markRegion,
unmark_region_visited: unmarkRegion,
create_bucket_list_item: createWish,
update_bucket_list_item: updateWish,
delete_bucket_list_item: deleteWish,
},
prompt: [
'Atlas reads and changes are drawn as cards (flags, counts, the bucket list as tiles): answer in a sentence or two instead of listing them again.',
'update_bucket_list_item and delete_bucket_list_item take the ids list_bucket_list gives; read it first when you have none.',
],
foreignIdGroups: ['atlas'],
};
@@ -0,0 +1,52 @@
import { logWarn } from '../audit/audit-log.logger';
import type { AtlasService } from './atlas.service';
import type { VisitStatus } from '@trek/shared';
/**
* Where the atlas stands after a write, as the MCP mark tools answer it: the
* country's own status the way the Atlas map paints it (null once it is off
* the map) and how many countries count as visited, plus the visited regions
* of that country for the region tools. Read through `stats()` and
* `visitedRegions()`, the same reads the Atlas page makes, so the assistant's
* "that makes 24 countries" and the map agree. REST answers stay as they were.
*/
interface AtlasStanding {
country_status: VisitStatus | null;
countries_visited: number;
}
export async function standingOf(
atlas: Pick<AtlasService, 'stats'>,
userId: number,
code: string,
): Promise<AtlasStanding> {
const stats = await atlas.stats(userId);
// A user without trips only has the countries marked by hand, which carry no status: they are visited.
const countries: ReadonlyArray<{ code: string; status?: VisitStatus }> = stats.countries;
const entry = countries.find((country) => country.code === code);
return { country_status: entry ? (entry.status ?? 'visited') : null, countries_visited: stats.stats.totalCountries };
}
/** The visited regions of one country, as the map paints them. */
export async function regionsVisitedOf(
atlas: Pick<AtlasService, 'visitedRegions'>,
userId: number,
countryCode: string,
): Promise<number> {
const { regions } = await atlas.visitedRegions(userId);
return (regions[countryCode] ?? []).filter((region) => region.status === 'visited').length;
}
/**
* A read made after a write that already landed and was broadcast: when it
* fails, the answer leaves `after` out (the card falls back to what it held)
* instead of turning a done write into a tool error the model would retry.
*/
export async function readAfterWrite<T>(read: () => Promise<T>): Promise<T | undefined> {
try {
return await read();
} catch (err) {
logWarn(`[atlas] reading the atlas back after a write failed: ${err instanceof Error ? err.message : String(err)}`);
return undefined;
}
}
+46 -8
View File
@@ -13,6 +13,7 @@ import {
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { readAfterWrite, regionsVisitedOf, standingOf } from './atlas-standing.helpers';
import { AtlasWritesService } from './atlas-writes.service';
import { AtlasService, BucketItemExistsError } from './atlas.service';
import { idSchema } from '@trek/shared';
@@ -64,6 +65,12 @@ function jsonContent(uri: string, data: unknown) {
* merged shape GET /regions has always returned instead of the raw manual
* rows; and get_atlas_stats carries the dashboard passport figures from
* GET /api/auth/travel-stats next to the atlas payload.
*
* For the assistant's cards the four mark tools also answer `after`: where
* the country stands once the write ran and how many countries (and, for a
* region, regions of that country) count as visited now (atlas-standing.helpers.ts;
* a failed read leaves `after` out, the write stands), and list_bucket_list answers what GET /bucket-list does, the only way to
* the ids update_bucket_list_item and delete_bucket_list_item take.
*/
@McpController()
export class AtlasMcp {
@@ -75,6 +82,21 @@ export class AtlasMcp {
// ── Bucket list ─────────────────────────────────────────────────────────
@Tool({
name: 'list_bucket_list',
description:
'List your travel bucket list: each wish with its id, name, country and region, target date, notes and when it was reached. The ids are what update_bucket_list_item and delete_bucket_list_item take.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'read' },
})
async listBucketList(_args: Record<string, never>, ctx: McpContext) {
// The same rows GET /bucket-list answers with, under the same `items` key.
const items = await this.atlas.bucketList(ctx.userId);
return ok({ items });
}
@Tool({
name: 'create_bucket_list_item',
description: 'Add a destination to your personal travel bucket list.',
@@ -171,8 +193,10 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
await this.writes.markCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
const code = country_code.toUpperCase();
await this.writes.markCountry(ctx.userId, code);
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
return ok({ success: true, country_code: code, after });
}
@Tool({
@@ -186,8 +210,10 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
await this.writes.unmarkCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
const code = country_code.toUpperCase();
await this.writes.unmarkCountry(ctx.userId, code);
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
return ok({ success: true, country_code: code, after });
}
// ── Atlas expanded ──────────────────────────────────────────────────────
@@ -276,13 +302,14 @@ export class AtlasMcp {
// (the legacy registrar passed them through verbatim, so a lowercase mark
// created a row REST's uppercased unmark could never hit).
const upperRegion = regionCode.toUpperCase();
await this.writes.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase());
const upperCountry = countryCode.toUpperCase();
await this.writes.markRegion(ctx.userId, upperRegion, regionName, upperCountry);
const row = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === upperRegion);
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
const region = row
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
: undefined;
return ok({ region });
return ok({ region, after: await readAfterWrite(() => this.regionStanding(ctx.userId, upperCountry)) });
}
@Tool({
@@ -298,8 +325,19 @@ export class AtlasMcp {
})
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
// Post-fold quirk fix: uppercase, matching the REST controller.
await this.writes.unmarkRegion(ctx.userId, regionCode.toUpperCase());
return ok({ success: true });
const code = regionCode.toUpperCase();
// The country is read before the unmark, which may take its last manual row
// along; a region derived from places carries its country in the code.
const marked = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === code);
const countryCode = marked?.country_code ?? code.split('-')[0] ?? '';
await this.writes.unmarkRegion(ctx.userId, code);
return ok({ success: true, after: await readAfterWrite(() => this.regionStanding(ctx.userId, countryCode)) });
}
/** Where a region's country stands after a region write, with that country's visited regions. */
private async regionStanding(userId: number, countryCode: string) {
const standing = await standingOf(this.atlas, userId, countryCode);
return { ...standing, regions_visited: await regionsVisitedOf(this.atlas, userId, countryCode) };
}
@Tool({
@@ -268,7 +268,11 @@ describe('Tool: list_visited_regions', () => {
await withHarness(user.id, async (h) => {
const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } });
expect(parseToolResult(unmark)).toEqual({ success: true });
// The places of the trip keep Italy on the map; only the region is gone.
expect(parseToolResult(unmark)).toEqual({
success: true,
after: { country_status: 'visited', countries_visited: 1, regions_visited: 0 },
});
const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' });
expect(tombstone).toBeTruthy();
@@ -369,7 +373,7 @@ describe('Tool: mark_region_visited', () => {
name: 'unmark_region_visited',
arguments: { regionCode: 'jp-13' },
});
expect(parseToolResult(unmark)).toEqual({ success: true });
expect(parseToolResult(unmark)).toMatchObject({ success: true });
expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull();
});
});
@@ -388,6 +392,8 @@ describe('Tool: mark_region_visited', () => {
expect(data.region.name).toBe('California');
expect(data.region.country_code).toBe('US');
expect(data.region.manuallyMarked).toBe(true);
// Where the atlas stands now, for the assistant's card: the region brought its country along.
expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1, regions_visited: 1 });
const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' });
expect(row).toBeTruthy();
});
@@ -419,8 +425,11 @@ describe('Tool: unmark_region_visited', () => {
name: 'unmark_region_visited',
arguments: { regionCode: 'IT-LO' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
// The last region of a country it was never marked for takes the country along.
expect(parseToolResult(result)).toEqual({
success: true,
after: { country_status: null, countries_visited: 0, regions_visited: 0 },
});
expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull();
});
});
+54 -3
View File
@@ -1,10 +1,12 @@
/**
* Unit tests for MCP atlas and bucket list tools:
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item.
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item,
* list_bucket_list.
*/
import { db as testDb } from '../../../src/db/database';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { countRows, findRow } from '../../helpers/factories/rows';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
@@ -55,11 +57,28 @@ describe('Tool: mark_country_visited', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.country_code).toBe('FR');
// Where the atlas stands now, which the assistant's card and answer read.
expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1 });
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'FR' });
expect(row).toBeTruthy();
});
});
it('MCP-ATLAS-AFTER-001: a failed read after the mark leaves `after` out, not the done write', async () => {
const { user } = createUser(testDb);
const stats = vi.spyOn(AtlasService.prototype, 'stats').mockRejectedValue(new Error('stats unavailable'));
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'NO' } });
expect(result.isError).toBeFalsy();
expect(parseToolResult(result)).toEqual({ success: true, country_code: 'NO' });
expect(await findRow(orm, VisitedCountries, { user: user.id, country_code: 'NO' })).toBeTruthy();
});
} finally {
stats.mockRestore();
}
});
it('is idempotent — marking twice does not error', async () => {
const { user } = createUser(testDb);
createVisitedCountry(testDb, user.id, 'JP');
@@ -92,8 +111,11 @@ describe('Tool: unmark_country_visited', () => {
createVisitedCountry(testDb, user.id, 'ES');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(parseToolResult(result)).toEqual({
success: true,
country_code: 'ES',
after: { country_status: null, countries_visited: 0 },
});
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'ES' });
expect(row).toBeNull();
});
@@ -250,3 +272,32 @@ describe('Tool: delete_bucket_list_item', () => {
});
});
});
// ---------------------------------------------------------------------------
// list_bucket_list
// ---------------------------------------------------------------------------
describe('Tool: list_bucket_list', () => {
it("MCP-ATLAS-BUCKET-001: answers the user's own wishes with their ids, shaped like GET /bucket-list", async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const kyoto = createBucketListItem(testDb, user.id, { name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
createBucketListItem(testDb, other.id, { name: "Other's Wish" });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
const data = parseToolResult(result) as { items: Array<Record<string, unknown>> };
expect(data.items).toHaveLength(1);
expect(data.items[0]).toMatchObject({ id: kyoto.id, name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
});
});
it('MCP-ATLAS-BUCKET-002: answers an empty list, and reads for a demo user too', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
expect(result.isError).toBeFalsy();
expect(parseToolResult(result)).toEqual({ items: [] });
});
});
});
@@ -0,0 +1,519 @@
/**
* The atlas cards: the passport of get_atlas_stats, one country of
* get_country_atlas_places, the wishes of list_bucket_list, the change cards
* of the seven atlas writes predicted from the turn's reads, the applied
* cards built from what the writes answer, and the way back from an added
* wish. Every card is checked against the card union and holds no address.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { bucketListOf, countryOf, passportOf } from '../../../../../../src/nest/assistant/cards/atlas/atlas-answers';
import { DELETE_ADDED_WISH } from '../../../../../../src/nest/assistant/cards/atlas/atlas-applied';
import {
ATLAS_CHANGES,
countryNameIn,
loadAtlas,
loadAtlasRegions,
loadBucket,
type AtlasLoaded,
} from '../../../../../../src/nest/assistant/cards/atlas/atlas-changes';
import {
atlasSnapshotOf,
bucketSnapshotOf,
regionsSnapshotOf,
} from '../../../../../../src/nest/assistant/cards/atlas/atlas-values';
import type { CardReader, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
import {
answerCardOf,
appliedCardOf,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
CARD_PROMPT_LINES,
TOOL_CARDS,
} from '../../../../../../src/nest/assistant/cards/registry';
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { assistantChangePreviewSchema, trekCardSchema, type TrekCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
const card = buildTripCard(1, 7, { trip: { title: 'Alps' } }, null, null);
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: '2026-10-22', airport: () => null };
/** The card parses as a card of the union and carries no address. */
function valid(built: TrekCard | null): TrekCard {
expect(built).not.toBeNull();
expect(trekCardSchema.safeParse(built).success).toBe(true);
expect(JSON.stringify(built)).not.toMatch(/https?:/);
return built as TrekCard;
}
/** A country as `stats()` answers it. */
function visit(code: string, places: number, first: string | null, last: string | null, status: string) {
return { code, placeCount: places, tripCount: places > 0 ? 1 : 0, firstVisit: first, lastVisit: last, status };
}
const STATS = {
stats: {
countries: [
visit('FR', 4, '2019-05-01', '2019-05-09', 'visited'),
visit('JP', 14, '2019-10-12', '2024-04-14', 'visited'),
visit('DE', 0, null, null, 'visited'),
visit('PT', 3, '2026-11-03', null, 'planned'),
visit('IS', 1, null, null, 'idea'),
],
stats: {
totalTrips: 3,
totalPlaces: 22,
totalCountries: 3,
totalCountriesPlanned: 1,
totalCountriesIdea: 1,
totalDays: 30,
totalCities: 9,
},
continents: { Europe: 2, Asia: 1, Atlantis: 4 },
continentsPlanned: { Europe: 1 },
nextTrip: { id: 3, title: 'Lisbon', start_date: '2026-11-03', daysUntil: 12 },
streak: 3,
},
travel: { countries: ['FR', 'JP', 'DE'], cities: ['paris'], totalDistanceKm: 18_230.6 },
};
const REGIONS = {
regions: {
JP: [
{ code: 'JP-26', name: 'Kyoto', placeCount: 6, status: 'visited' },
{ code: 'JP-13', name: 'Tokyo', placeCount: 8, status: 'visited' },
],
DE: [{ code: 'DE-BY', name: 'Bavaria', placeCount: 0, status: 'visited', manuallyMarked: true }],
},
};
const WISH = {
id: 5,
user_id: 1,
name: 'Northern lights',
lat: 64.1,
lng: -21.9,
country_code: 'IS',
notes: 'Glacier hike',
created_at: '2026-01-01 10:00:00',
target_date: '2027-02',
visited_at: null,
visited_source: null,
region_code: null,
};
const PETRA = { ...WISH, id: 8, name: 'Petra', country_code: 'JO' };
const BUCKET = {
items: [
WISH,
{ ...WISH, id: 6, name: 'Kyoto in autumn', country_code: 'JP', lat: null, lng: null, target_date: null },
{ ...WISH, id: 7, name: 'Machu Picchu', country_code: 'PER', target_date: '2026-05', notes: null },
{ ...PETRA, visited_at: '2025-04-02 08:00:00', visited_source: 'dawarich' },
],
};
const loaded: AtlasLoaded = {
atlas: atlasSnapshotOf(STATS),
regions: regionsSnapshotOf(REGIONS),
bucket: bucketSnapshotOf(BUCKET),
};
describe('atlas answer cards', () => {
it('ASTATLAS-001: the passport takes its figures, flags in the Atlas order, known continents and the flown distance', () => {
const passport = valid(passportOf(STATS));
expect(passport).toMatchObject({
kind: 'atlasPassport',
countries: { visited: 3, planned: 1, idea: 1 },
totals: { trips: 3, days: 30, places: 22, cities: 9, flownKm: 18_231 },
moreFlags: 0,
nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
streakYears: 3,
});
// Visited latest first, the one without a date last, then planned; an idea is left out.
expect(passport.kind === 'atlasPassport' && passport.flags).toEqual([
{ code: 'JP', status: 'visited' },
{ code: 'FR', status: 'visited' },
{ code: 'DE', status: 'visited' },
{ code: 'PT', status: 'planned' },
]);
expect(passport.kind === 'atlasPassport' && passport.continents).toEqual([
{ name: 'Europe', visited: 2, planned: 1 },
{ name: 'Asia', visited: 1, planned: 0 },
]);
});
it('ASTATLAS-002: a user without trips gets the short form: no planned or idea counts, cities from the passport, nothing flown', () => {
const passport = valid(
passportOf({
stats: {
countries: [{ code: 'NO', placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
trips: [],
stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 },
},
travel: { cities: ['oslo', 'bergen'], totalDistanceKm: 0 },
}),
);
expect(passport).toMatchObject({
countries: { visited: 1, planned: 0, idea: 0 },
totals: { cities: 2, flownKm: null },
flags: [{ code: 'NO', status: 'visited' }],
continents: [],
nextTrip: null,
streakYears: null,
});
expect(passportOf({ stats: 'nope' })).toBeNull();
});
it('ASTATLAS-003: forty flags at most, the rest counted', () => {
const countries = Array.from({ length: 45 }, (_, i) => ({
code: `${String.fromCharCode(65 + Math.floor(i / 26))}${String.fromCharCode(65 + (i % 26))}`,
status: 'visited',
}));
const passport = valid(passportOf({ stats: { ...STATS.stats, countries }, travel: STATS.travel }));
expect(passport.kind === 'atlasPassport' && passport.flags).toHaveLength(40);
expect(passport).toMatchObject({ moreFlags: 5 });
});
it('ASTATLAS-004: a country reads its trips latest first and its places in their order, masked and cut', () => {
const trips = [
{ id: 1, title: 'Tokyo food week', start_date: '2019-10-12', end_date: '2019-10-19' },
{ id: 2, title: 'Kyoto with ana@example.com', start_date: '2024-04-03', end_date: '2024-04-14' },
...[3, 4, 5].map((id) => ({ id, title: `Trip ${id}`, start_date: null, end_date: null })),
];
const places = [
{ id: 10, name: 'Tsukiji', trip_id: 1 },
{ id: 11, name: 'Fushimi Inari', trip_id: 2 },
...Array.from({ length: 9 }, (_, i) => ({ id: 20 + i, name: `Spot ${i}`, trip_id: 2 })),
];
const raw = { places, trips, manually_marked: false, marked_source: null, status: 'visited' };
const country = valid(countryOf({ countryCode: 'jp' }, raw));
expect(country).toMatchObject({
kind: 'atlasCountry',
code: 'JP',
status: 'visited',
markedBy: null,
placeCount: 11,
tripCount: 5,
moreTrips: 1,
morePlaces: 3,
change: null,
});
if (country.kind !== 'atlasCountry') throw new Error('not a country');
expect(country.trips.map((trip) => trip.id)).toEqual([2, 1, 3, 4]);
expect(country.trips[0]?.title).not.toContain('ana@example.com');
expect(country.places.slice(0, 2)).toEqual(['Fushimi Inari', 'Spot 0']);
expect(country.places).not.toContain('Tsukiji');
});
it('ASTATLAS-005: a country nothing put on the map says so, one marked from recordings names it, a bad code gives none', () => {
const empty = { places: [], trips: [], manually_marked: false, status: 'idea' };
expect(countryOf({ countryCode: 'PE' }, empty)).toMatchObject({ status: null, placeCount: 0 });
const recorded = { ...empty, manually_marked: true, marked_source: 'dawarich', status: 'visited' };
const norway = valid(countryOf({ countryCode: 'NO' }, recorded));
expect(norway).toMatchObject({ status: 'visited', markedBy: 'dawarich' });
expect(countryOf({ countryCode: 'NOR' }, {})).toBeNull();
expect(countryOf({}, {})).toBeNull();
});
it('ASTATLAS-006: the bucket list shows open wishes soonest first, then the reached ones, without coordinates', () => {
const bucket = valid(bucketListOf(BUCKET));
if (bucket.kind !== 'atlasBucket') throw new Error('not a bucket');
expect(bucket.items.map((item) => item.id)).toEqual([7, 5, 6, 8]);
expect(bucket.items[0]).toMatchObject({ countryCode: null, targetDate: '2026-05', notes: null });
expect(bucket.items[3]).toMatchObject({ reachedOn: '2025-04-02', reachedFrom: 'dawarich' });
expect(JSON.stringify(bucket)).not.toMatch(/lat|lng|user_id|64\.1/);
const many = { items: Array.from({ length: 15 }, (_, i) => ({ ...WISH, id: i + 1, name: `Wish ${i}` })) };
expect(bucketListOf(many)).toMatchObject({ more: 3 });
expect(bucketListOf({ items: [] })).toBeNull();
expect(bucketListOf('That is not a list')).toBeNull();
});
});
describe('atlas change cards', () => {
it('ASTATLAS-007: marking a country adds one unless it is visited already; an unread atlas leaves the counter out', () => {
const peru = valid(ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'pe' }, loaded));
expect(peru).toMatchObject({
code: 'PE',
status: null,
change: { op: 'mark', after: 'visited', countries: { before: 3, after: 4 } },
});
const portugal = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'PT' }, loaded);
expect(portugal).toMatchObject({ status: 'planned', placeCount: 3, change: { countries: { after: 4 } } });
const france = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, loaded);
expect(france).toMatchObject({ change: { countries: { before: 3, after: 3 } } });
const blind = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, null);
expect(valid(blind)).toMatchObject({ status: null, change: { countries: null } });
expect(ATLAS_CHANGES.mark_country_visited(ctx, {}, loaded)).toBeNull();
});
it('ASTATLAS-008: an unmark takes a country off the map unless its trips keep it, where the trips decide', () => {
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'DE' }, loaded)).toMatchObject({
change: { op: 'unmark', after: 'none', countries: { before: 3, after: 2 } },
});
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'JP' }, loaded)).toMatchObject({
change: { after: 'unknown', countries: { before: 3, after: null } },
});
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'PT' }, loaded)).toMatchObject({
change: { after: 'planned', countries: { before: 3, after: 3 } },
});
expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'AU' }, loaded)).toMatchObject({
status: null,
change: { after: 'none', countries: { before: 3, after: 3 } },
});
});
it('ASTATLAS-009: a region mark counts the regions of its country and moves the country like a mark', () => {
const osaka = valid(
ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'jp-27', regionName: 'Osaka', countryCode: 'jp' }, loaded),
);
expect(osaka).toMatchObject({
code: 'JP',
change: {
op: 'mark',
region: { code: 'JP-27', name: 'Osaka', before: null, after: 'visited' },
countries: { before: 3, after: 3 },
regions: { before: 2, after: 3 },
},
});
const kyoto = { regionCode: 'JP-26', regionName: 'Kyoto', countryCode: 'JP' };
const again = ATLAS_CHANGES.mark_region_visited(ctx, kyoto, loaded);
expect(again).toMatchObject({ change: { region: { before: 'visited' }, regions: { before: 2, after: 2 } } });
expect(ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'Osaka', countryCode: 'JP' }, loaded)).toBeNull();
});
it('ASTATLAS-010: a region unmark keeps the country while others are left; the last one leaves it to the trips', () => {
expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'JP-26' }, loaded)).toMatchObject({
code: 'JP',
change: {
op: 'unmark',
region: { code: 'JP-26', name: 'Kyoto', before: 'visited', after: 'none' },
after: 'visited',
countries: { before: 3, after: 3 },
regions: { before: 2, after: 1 },
},
});
const last = valid(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'DE-BY' }, loaded));
expect(last).toMatchObject({
change: { after: 'unknown', countries: { before: 3, after: null }, regions: { before: 1, after: 0 } },
});
// A region the snapshot does not know is named by its code, its country by the code's prefix.
expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'FR-IDF' }, loaded)).toMatchObject({
code: 'FR',
change: { region: { name: 'FR-IDF', before: null } },
});
});
it('ASTATLAS-011: an add shows the wish from its arguments and knows a duplicate, which does not grow the list', () => {
const patagonia = { name: 'Patagonia', country_code: 'ar', target_date: '2028', notes: 'Torres del Paine' };
const add = valid(ATLAS_CHANGES.create_bucket_list_item(ctx, { ...patagonia, lat: -50.9, lng: -73.4 }, loaded));
expect(add).toMatchObject({
items: [{ id: null, name: 'Patagonia', countryCode: 'AR', targetDate: '2028', notes: 'Torres del Paine' }],
change: { op: 'add', duplicate: false, wishes: { before: 4, after: 5 } },
});
expect(JSON.stringify(add)).not.toContain('-50.9');
const dup = ATLAS_CHANGES.create_bucket_list_item(
ctx,
{ name: ' northern LIGHTS ', country_code: 'is', target_date: '2027-02', lat: 64.1, lng: -21.9 },
loaded,
);
expect(dup).toMatchObject({ change: { duplicate: true, wishes: { before: 4, after: 4 } } });
const otherDate = ATLAS_CHANGES.create_bucket_list_item(
ctx,
{ name: 'Northern lights', country_code: 'IS', target_date: '2028-02', lat: 64.1, lng: -21.9 },
loaded,
);
expect(otherDate).toMatchObject({ change: { duplicate: false } });
expect(ATLAS_CHANGES.create_bucket_list_item(ctx, {}, loaded)).toBeNull();
});
it('ASTATLAS-012: a change shows the wish as it will read, a removal as it reads, and an unknown id draws no card', () => {
const update = valid(
ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 5, name: 'Aurora', target_date: '2027-03' }, loaded),
);
expect(update).toMatchObject({
items: [{ id: 5, name: 'Aurora', targetDate: '2027-03', notes: 'Glacier hike', countryCode: 'IS' }],
change: { op: 'update', before: { name: 'Northern lights', targetDate: '2027-02' }, duplicate: false },
});
const clash = ATLAS_CHANGES.update_bucket_list_item(
ctx,
{ itemId: 6, name: 'Northern lights', country_code: 'IS', target_date: '2027-02', lat: 64.1, lng: -21.9 },
loaded,
);
expect(clash).toMatchObject({ change: { duplicate: true } });
expect(ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 99, name: 'x' }, loaded)).toBeNull();
const remove = valid(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, loaded));
expect(remove).toMatchObject({
items: [{ id: 6, name: 'Kyoto in autumn' }],
change: { op: 'remove', before: { id: 6 }, wishes: { before: 4, after: 3 } },
});
expect(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, null)).toBeNull();
});
it('ASTATLAS-013: the held card names the country in the user language, under the tool label in the atlas', () => {
expect(countryNameIn({ locale: 'de' }, 'FR')).toBe('Frankreich');
expect(countryNameIn({ locale: 'not a locale!' }, 'FR')).toBe('France');
const german = { ...ctx, locale: 'de' };
const preview = describeToolCall('mark_country_visited', 'tc-1', { country_code: 'fr' }, german, loaded);
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
expect(preview).toMatchObject({
verbKey: 'genericCreate',
subject: 'Frankreich',
tool: 'mark_country_visited',
domain: 'atlas',
undoable: false,
card: { kind: 'atlasCountry', code: 'FR' },
});
const region = describeToolCall('unmark_region_visited', 'tc-2', { regionCode: 'JP-26' }, ctx, loaded);
expect(region).toMatchObject({ verbKey: 'genericRemove', subject: 'Kyoto' });
expect(region.rows).toEqual([{ op: 'add', labelKey: 'location', before: null, after: 'Japan' }]);
const wish = describeToolCall('update_bucket_list_item', 'tc-3', { itemId: 5, notes: 'With Ana' }, ctx, loaded);
expect(wish).toMatchObject({
subject: 'Northern lights',
rows: [{ op: 'change', labelKey: 'notes', before: 'Glacier hike', after: 'With Ana' }],
});
const add = describeToolCall('create_bucket_list_item', 'tc-4', { name: 'Petra' }, ctx, loaded);
expect(add).toMatchObject({ undoable: true, subject: 'Petra' });
expect(describeToolCall('delete_bucket_list_item', 'tc-5', { itemId: 8 }, ctx, loaded)).toMatchObject({
verbKey: 'genericRemove',
subject: 'Petra',
});
});
});
describe('atlas lookups, applied cards and undo', () => {
it('ASTATLAS-014: each change reads only what it needs, and a failed read leaves its snapshot empty', async () => {
const answers: Record<string, unknown> = { get_atlas_stats: STATS, list_visited_regions: REGIONS };
const read = vi.fn<CardReader>((tool) => Promise.resolve(answers[tool] ?? null));
expect((await loadAtlas({}, read)).atlas?.visited).toBe(3);
expect(read).toHaveBeenCalledWith('get_atlas_stats');
const both = await loadAtlasRegions({}, read);
expect(both.regions?.get('JP-26')).toEqual({ name: 'Kyoto', country: 'JP', status: 'visited' });
expect(both.bucket).toBeNull();
expect((await loadBucket({}, read)).bucket).toBeNull();
expect(read).toHaveBeenCalledTimes(4);
expect(atlasSnapshotOf({ stats: {} })).toBeNull();
expect(regionsSnapshotOf('nope')).toBeNull();
});
it('ASTATLAS-015: the applied country takes the standing and the counters the write answered', () => {
const held = describeToolCall('unmark_country_visited', 'tc-6', { country_code: 'JP' }, ctx, loaded);
const applied = appliedCardOf('unmark_country_visited', {
args: { country_code: 'JP' },
result: { success: true, country_code: 'JP', after: { country_status: 'visited', countries_visited: 3 } },
preview: held,
});
expect(valid(applied)).toMatchObject({ change: { after: 'visited', countries: { before: 3, after: 3 } } });
const region = describeToolCall('unmark_region_visited', 'tc-7', { regionCode: 'DE-BY' }, ctx, loaded);
const gone = appliedCardOf('unmark_region_visited', {
args: { regionCode: 'DE-BY' },
result: { success: true, after: { country_status: null, countries_visited: 2, regions_visited: 0 } },
preview: region,
});
expect(gone).toMatchObject({
change: { after: 'none', countries: { after: 2 }, region: { after: 'none' }, regions: { before: 1, after: 0 } },
});
// Without a held card there is nothing to build on.
const blind = { args: { country_code: 'JP' }, result: {}, preview: null };
expect(appliedCardOf('mark_country_visited', blind)).toBeNull();
});
it('ASTATLAS-016: an applied wish carries its stored id, a removal keeps its held card', () => {
const held = describeToolCall('create_bucket_list_item', 'tc-8', { name: 'Patagonia' }, ctx, loaded);
const added = appliedCardOf('create_bucket_list_item', {
args: { name: 'Patagonia' },
result: { item: { ...WISH, id: 41, name: 'Patagonia', country_code: 'AR', notes: 'mail me at ana@example.com' } },
preview: held,
});
expect(valid(added)).toMatchObject({
items: [{ id: 41, name: 'Patagonia', countryCode: 'AR' }],
change: { op: 'add', wishes: { before: 4, after: 5 } },
});
expect(JSON.stringify(added)).not.toContain('ana@example.com');
const removal = describeToolCall('delete_bucket_list_item', 'tc-9', { itemId: 5 }, ctx, loaded);
const input = { args: { itemId: 5 }, result: { success: true }, preview: removal };
const removed = appliedCardOf('delete_bucket_list_item', input);
expect(removed).toEqual(removal.card);
});
it('ASTATLAS-017: an added wish is taken back while it reads as added, never once edited or reached', () => {
const result = { item: { ...WISH, id: 41 } };
expect(ASSISTANT_UNDO_MAP.create_bucket_list_item?.tool).toBe('delete_bucket_list_item');
expect(DELETE_ADDED_WISH.args({}, result, null)).toEqual({ itemId: 41 });
expect(DELETE_ADDED_WISH.args({}, { success: false }, null)).toBeNull();
const reads = DELETE_ADDED_WISH.reads?.({}, result) ?? [];
expect(reads).toEqual([{ tool: 'list_bucket_list', args: {} }]);
const snapshot = (items: unknown[]) => ({
summary: null,
places: null,
reads: new Map([[readKey('list_bucket_list', {}), { items }]]),
});
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41 }]))).toBe(true);
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, notes: 'Edited' }]))).toBe(false);
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, visited_at: '2026-10-01' }]))).toBe(false);
expect(DELETE_ADDED_WISH.unchanged(result, snapshot([]))).toBe(false);
});
it('ASTATLAS-018: the registry holds the atlas tools, guards their cards and adds the two prompt lines', () => {
const marks = ['mark_country_visited', 'unmark_country_visited', 'mark_region_visited', 'unmark_region_visited'];
for (const tool of marks) {
expect(ASSISTANT_WRITE_TOOLS[tool]?.entity).toBe('atlas');
expect(TOOL_CARDS[tool]?.load).toBeTypeOf('function');
}
expect(ASSISTANT_WRITE_TOOLS.update_bucket_list_item).toEqual({ entity: 'atlas', verbKey: 'genericUpdate' });
const input = { toolCallId: 'tc-10', card, today: '2026-10-22' };
const passport = answerCardOf('get_atlas_stats', { ...input, args: {}, value: STATS });
expect(valid(passport)).toMatchObject({ kind: 'atlasPassport' });
const bucket = answerCardOf('list_bucket_list', { ...input, args: {}, value: BUCKET });
expect(bucket).toMatchObject({ kind: 'atlasBucket' });
expect(answerCardOf('get_country_atlas_places', { ...input, args: { countryCode: 'x' }, value: {} })).toBeNull();
expect(CARD_PROMPT_LINES.some((line) => line.includes('list_bucket_list'))).toBe(true);
});
it('ASTATLAS-019: the duplicate check compares a country as stored and a name only trimmed, as the service does', () => {
const stored = bucketSnapshotOf({
items: [
{ ...WISH, id: 20, name: 'Kyoto', country_code: 'jp', lat: null, lng: null, target_date: null },
{ ...WISH, id: 21, name: 'Osaka Castle', country_code: 'JP', lat: null, lng: null, target_date: null },
],
});
const lower: AtlasLoaded = { atlas: null, regions: null, bucket: stored };
// An update keeps its country as given: 'jp' meets the stored 'jp', 'JP' does not.
const same = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'jp' }, lower);
expect(same).toMatchObject({ change: { duplicate: true } });
const upper = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'JP' }, lower);
expect(upper).toMatchObject({ change: { duplicate: false } });
// An add is stored upper-cased by its schema, so 'jp' lands as 'JP' and misses the stored 'jp'.
const add = ATLAS_CHANGES.create_bucket_list_item(ctx, { name: 'Kyoto', country_code: 'jp' }, lower);
expect(add).toMatchObject({ change: { duplicate: false } });
// Inner spaces stay, as the service trims the ends only: the double space meets the stored one.
const spaced = { itemId: 20, name: ' osaka castle ', country_code: 'JP' };
expect(ATLAS_CHANGES.update_bucket_list_item(ctx, spaced, lower)).toMatchObject({ change: { duplicate: true } });
// A blank name keeps the stored one, so the wish clashes as it is.
const blank = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 20, name: ' ', country_code: 'JP' }, lower);
expect(blank).toMatchObject({ items: [{ name: 'Kyoto' }], change: { duplicate: false } });
});
it('ASTATLAS-020: a wish moved to another country loses its region, one kept in its country keeps it', () => {
const regional = bucketSnapshotOf({ items: [{ ...WISH, id: 30, region_code: 'IS-1' }] });
const withRegion: AtlasLoaded = { atlas: null, regions: null, bucket: regional };
const moved = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, country_code: 'NO' }, withRegion);
expect(moved).toMatchObject({
items: [{ countryCode: 'NO', regionCode: null }],
change: { before: { regionCode: 'IS-1' } },
});
const kept = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, notes: 'In March' }, withRegion);
expect(kept).toMatchObject({ items: [{ countryCode: 'IS', regionCode: 'IS-1', notes: 'In March' }] });
});
it('ASTATLAS-021: a wish change is named by its new name, else its stored one, else its id', () => {
const subject = (tool: string, args: Record<string, unknown>) =>
describeToolCall(tool, 'tc-21', args, ctx, loaded).subject;
expect(subject('update_bucket_list_item', { itemId: 5, name: 'Aurora' })).toBe('Aurora');
expect(subject('update_bucket_list_item', { itemId: 5, target_date: '2027-03' })).toBe('Northern lights');
expect(subject('update_bucket_list_item', { itemId: 99, notes: 'x' })).toBe('#99');
// A removal never takes a name from its arguments.
expect(subject('delete_bucket_list_item', { itemId: 6, name: 'Ignored' })).toBe('Kyoto in autumn');
expect(subject('delete_bucket_list_item', { itemId: 99 })).toBe('#99');
expect(subject('delete_bucket_list_item', {})).toBe('');
});
});