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({