mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(assistant): passport, country and bucket list cards
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user