diff --git a/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx b/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx
new file mode 100644
index 000000000..6bc5aa3d7
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx
@@ -0,0 +1,169 @@
+import type { AssistantMessage, AtlasBucketCard as BucketCard, AtlasBucketEntry } from '@trek/shared';
+import { CalendarDays, Check, NotebookPen, Star, Tag, TriangleAlert } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
+import CountryFlag from '../../../shared/CountryFlag';
+import type { ChangeItem } from '../../assistantParts';
+import type { CardEntry } from '../cardTypes';
+import { FactChange } from '../parts/CardFacts';
+import CardFrame from '../parts/CardFrame';
+import CardHeader from '../parts/CardHeader';
+import { CardOpenButton } from '../parts/CardOpen';
+import { CardMore } from '../parts/CardRows';
+import { shortDay, wishMonth } from './atlasCardModel';
+import { ATLAS_PAGE } from './atlasPage';
+import { ChangeFlag } from './AtlasParts';
+import { useAtlasCard } from './useAtlasCard';
+
+/**
+ * The bucket list the way the Atlas page lists it: each wish as a tile with
+ * its flag (a star without a country), its name, the month it is planned for
+ * and its notes, a tick with the day for one that came true. In a change card
+ * the one wish it adds, changes or removes is the row.
+ */
+
+const ICON = { size: 10, strokeWidth: 2.2, 'aria-hidden': true } as const;
+
+function WishTile({ wish }: { wish: AtlasBucketEntry }) {
+ const { t } = useTranslation();
+ const { countryName, locale } = useAtlasCard();
+ const month = wishMonth(wish.targetDate, locale);
+ return (
+
+
+ {wish.countryCode ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {wish.name}
+
+ {month && (
+ }>{month}
+ )}
+ {wish.notes && {wish.notes}
}
+ {wish.reachedOn && (
+
+
+ {t('assistant.card.atlas.reached', { date: shortDay(wish.reachedOn, locale) })}
+
+ )}
+
+ );
+}
+
+/**
+ * The wishes of an answer as tiles; two reads in one answer share the tiles,
+ * each wish once. The list is as long as the longest read says it is (what a
+ * read shows plus what it left out), never shorter than the wishes shown.
+ */
+export default function AtlasBucketBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
+ const { t } = useTranslation();
+ const { roomy, limits } = useAtlasCard();
+ const seen = new Set();
+ const wishes: AtlasBucketEntry[] = [];
+ let total = 0;
+ for (const entry of cards) {
+ if (entry.card.kind !== 'atlasBucket') continue;
+ total = Math.max(total, entry.card.items.length + entry.card.more);
+ for (const wish of entry.card.items) {
+ const key = wish.id === null ? `new-${wish.name}` : String(wish.id);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ wishes.push(wish);
+ }
+ }
+ if (wishes.length === 0) return null;
+ const shown = wishes.slice(0, limits.wishes);
+ const count = Math.max(total, wishes.length);
+ return (
+
+ }
+ />
+
+ {shown.map((wish) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** A wish change's lead: its flag, or a star without a country, with what the change does on its corner. */
+export function AtlasBucketLead({ card, item }: { card: BucketCard; item: ChangeItem }) {
+ return ;
+}
+
+/**
+ * When a wish is planned for (a date taken away shows struck through) and the
+ * region it marks; that an add would repeat a wish already there.
+ */
+export function AtlasBucketFacts({ card }: { card: BucketCard; item: ChangeItem }) {
+ const { t } = useTranslation();
+ const { locale } = useAtlasCard();
+ const wish = card.items[0];
+ const before = card.change?.op === 'update' ? card.change.before : null;
+ const month = wishMonth(wish.targetDate, locale);
+ const monthBefore = before ? wishMonth(before.targetDate, locale) : null;
+ return (
+ <>
+ {month && (
+
+ )}
+ {!month && monthBefore && (
+
+ )}
+ {wish.regionCode && (
+ }>{wish.regionCode}
+ )}
+ {card.change?.duplicate && (
+ }>
+ {t('assistant.card.atlas.duplicate')}
+
+ )}
+ >
+ );
+}
+
+/**
+ * Under a wish change: the notes of a new wish, the name and notes a change
+ * gives an old one (notes taken away show struck through).
+ */
+export function AtlasBucketBody({ card }: { card: BucketCard; item: ChangeItem }) {
+ const { t } = useTranslation();
+ const wish = card.items[0];
+ const change = card.change;
+ if (change?.op === 'update' && change.before) {
+ const before = change.before;
+ const renamed = before.name !== wish.name;
+ const renoted = before.notes !== wish.notes;
+ if (!renamed && !renoted) return null;
+ return (
+
+ {renamed && }
+ {renoted && (
+
+ )}
+
+ );
+ }
+ if (change?.op !== 'add' || !wish.notes) return null;
+ return {wish.notes}
;
+}
diff --git a/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx b/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx
new file mode 100644
index 000000000..0e91370e4
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx
@@ -0,0 +1,187 @@
+/**
+ * FE-AI-ATLAS-001 to FE-AI-ATLAS-006
+ *
+ * The atlas answer cards as the chat draws them: the passport with its
+ * figures, flags, continents, next trip and streak; a country with its trips
+ * and places; the bucket list as wish tiles; every one leading to the Atlas,
+ * and the pure look that names the atlas tools, steps and follow-ups.
+ */
+import type { AssistantMessage, AtlasBucketEntry, AtlasCountryCard, AtlasPassportCard, TrekCard } from '@trek/shared';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
+import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
+import { CARD_LOOKS, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks';
+import type { CardEntry } from '../cardTypes';
+import AtlasBucketBlock from './AtlasBucketCard';
+import AtlasCountryBlock from './AtlasCountryCard';
+import AtlasPassportBlock from './AtlasPassportCard';
+
+const MESSAGE = { id: 'm1', role: 'assistant', parts: [] } as unknown as AssistantMessage;
+const CODES = 'JP FR IT ES PT GR HR AT CH DE NL BE NO SE DK IS FI PL'.split(' ');
+
+function inView(ui: ReactNode, view: Partial = {}) {
+ const value: AssistantView = { tripId: 4, variant: 'window', ...view };
+ return render({ui} );
+}
+
+const entries = (...cards: TrekCard[]): CardEntry[] =>
+ cards.map((card, index) => ({ toolCallId: `call_${index}`, card }));
+
+const passport: AtlasPassportCard = {
+ kind: 'atlasPassport',
+ countries: { visited: 23, planned: 4, idea: 0 },
+ totals: { trips: 48, days: 410, places: 990, cities: 312, flownKm: 182_300 },
+ flags: [
+ ...CODES.map((code) => ({ code, status: 'visited' as const })),
+ { code: 'MA', status: 'planned' as const },
+ ],
+ moreFlags: 4,
+ continents: [
+ { name: 'Europe', visited: 14, planned: 2 },
+ { name: 'Asia', visited: 5, planned: 0 },
+ { name: 'Africa', visited: 0, planned: 1 },
+ ],
+ nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
+ streakYears: 3,
+};
+
+const japan: AtlasCountryCard = {
+ kind: 'atlasCountry',
+ code: 'JP',
+ status: 'planned',
+ markedBy: 'dawarich',
+ placeCount: 14,
+ tripCount: 2,
+ trips: [
+ { id: 1, title: 'Kyoto in spring', startDate: '2024-04-03', endDate: '2024-04-14' },
+ { id: 2, title: 'Tokyo food week', startDate: '2019-10-12', endDate: null },
+ ],
+ moreTrips: 1,
+ places: ['Fushimi Inari', 'Kinkaku-ji'],
+ morePlaces: 12,
+ change: null,
+};
+
+function wish(id: number | null, name: string, extra: Partial = {}): AtlasBucketEntry {
+ return {
+ id,
+ name,
+ countryCode: 'IS',
+ regionCode: null,
+ targetDate: null,
+ notes: null,
+ reachedOn: null,
+ reachedFrom: null,
+ ...extra,
+ };
+}
+
+describe('atlas answer cards', () => {
+ it('FE-AI-ATLAS-001: the passport shows the visited number, the figures, sixteen flags and the rest counted', () => {
+ const onOpen = vi.fn();
+ inView( , { onOpen });
+ expect(screen.getByRole('region', { name: 'Your atlas' })).toBeInTheDocument();
+ expect(screen.getByText('23')).toBeInTheDocument();
+ expect(screen.getByText('4 planned')).toBeInTheDocument();
+ for (const label of ['Trips', 'Days', 'Cities', 'Flown']) expect(screen.getByText(label)).toBeInTheDocument();
+ expect(screen.getByText('182300 km')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Morocco, Planned' })).toBeNull();
+ expect(screen.getByText('7 more')).toBeInTheDocument();
+ expect(screen.getByText('Europe')).toBeInTheDocument();
+ expect(screen.queryByText('Africa')).toBeNull();
+ expect(screen.getByText('Next trip: Lisbon in 12 days')).toBeInTheDocument();
+ expect(screen.getByText('3 years in a row')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: '7 more' }));
+ expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
+ fireEvent.click(screen.getByRole('button', { name: /Open Atlas/ }));
+ expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
+ });
+
+ it('FE-AI-ATLAS-002: a roomy window shows more flags, a trip-less user only the number and the flags, nothing flown no figure', () => {
+ const roomy = inView( , { density: 'roomy' });
+ expect(screen.getByRole('img', { name: 'Morocco, Planned' })).toHaveClass('opacity-60');
+ expect(screen.getByText('4 more')).toBeInTheDocument();
+ roomy.unmount();
+
+ const tripless = {
+ ...passport,
+ totals: { trips: 0, days: 0, places: 0, cities: 0, flownKm: null },
+ continents: [],
+ nextTrip: null,
+ streakYears: null,
+ };
+ inView( , { variant: 'fullscreen' });
+ expect(screen.queryByText('Trips')).toBeNull();
+ expect(screen.queryByText('Flown')).toBeNull();
+ expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument();
+ expect(screen.queryByText(/Next trip/)).toBeNull();
+ });
+
+ it('FE-AI-ATLAS-003: a country reads its standing, continent, counts, latest trips, places and its recordings', () => {
+ const korea: TrekCard = { ...japan, code: 'KR', status: null, markedBy: null };
+ inView( );
+ expect(screen.getByText('Japan')).toBeInTheDocument();
+ expect(screen.getByText('South Korea')).toBeInTheDocument();
+ expect(screen.getByText('Planned')).toBeInTheDocument();
+ expect(screen.getByText('Not on your map')).toBeInTheDocument();
+ expect(screen.getAllByText('Asia')).toHaveLength(2);
+ expect(screen.getAllByText('2 trips')).toHaveLength(2);
+ expect(screen.getAllByText('14 places')).toHaveLength(2);
+ expect(screen.getAllByText('Apr 3, 2024 to Apr 14, 2024')).toHaveLength(2);
+ expect(screen.getAllByText('Oct 12, 2019')).toHaveLength(2);
+ expect(screen.getAllByText('12 more')).toHaveLength(2);
+ expect(screen.getAllByText('1 more')).toHaveLength(2);
+ expect(screen.getAllByText('From your recordings')).toHaveLength(1);
+ });
+
+ it('FE-AI-ATLAS-004: the bucket list shows six tiles on the phone, each wish once, counted by its longest read', () => {
+ const onOpen = vi.fn();
+ const first: TrekCard = {
+ kind: 'atlasBucket',
+ items: [
+ wish(1, 'Northern lights', { targetDate: '2027-02', notes: 'Glacier hike' }),
+ wish(2, 'Petra', { countryCode: null, reachedOn: '2025-04-02', reachedFrom: 'dawarich' }),
+ ...[3, 4, 5].map((id) => wish(id, `Wish ${id}`)),
+ ],
+ more: 3,
+ change: null,
+ };
+ const later = [wish(5, 'Wish 5'), wish(6, 'Wish 6'), wish(7, 'Wish 7')];
+ const second: TrekCard = { kind: 'atlasBucket', items: later, more: 0, change: null };
+ inView( , { variant: 'fullscreen', onOpen });
+ expect(screen.getByRole('region', { name: 'Bucket List' })).toBeInTheDocument();
+ expect(screen.getAllByRole('listitem')).toHaveLength(6);
+ expect(screen.getByText('Feb 2027')).toBeInTheDocument();
+ expect(screen.getByText('Glacier hike')).toBeInTheDocument();
+ expect(screen.getByText('Reached on Apr 2, 2025')).toBeInTheDocument();
+ expect(screen.queryByText('Wish 7')).toBeNull();
+ // The first read knows 8 wishes (5 shown, 3 left out), the second only 3: 8 in all, 6 shown, 2 more.
+ fireEvent.click(screen.getByRole('button', { name: '2 more' }));
+ expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
+ });
+
+ it('FE-AI-ATLAS-005: blocks of another kind or without a card of theirs draw nothing', () => {
+ const other = entries({ kind: 'tourList' } as TrekCard);
+ const { container } = inView(
+ <>
+
+
+
+ >
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('FE-AI-ATLAS-006: the look names the atlas tools and reads, opens the Atlas for an applied change and asks next', () => {
+ expect(TOOL_LABELS.unmark_region_visited?.labelKey).toBe('assistant.tool.unmark_region_visited');
+ expect(TOOL_LABELS.mark_country_visited?.labelKey).toBe('assistant.tool.mark_country_visited');
+ expect(STEP_LOOKS.list_bucket_list?.stepKey).toBe('assistant.step.bucketList');
+ const applied = { toolCallId: 'c', entity: 'atlas' as const, ids: [], dayId: null, undoUntil: null };
+ expect(ENTITY_LOOK.atlas.open?.(applied, null)).toEqual({ kind: 'page', path: '/atlas' });
+ expect(ENTITY_LOOK.atlas.onMap).toBeUndefined();
+ expect(CARD_LOOKS.atlasBucket.followUps?.map((followUp) => followUp.id)).toEqual(['atlasPlanWish', 'atlasAddWish']);
+ });
+});
diff --git a/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx b/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx
new file mode 100644
index 000000000..c9a68dcdf
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx
@@ -0,0 +1,172 @@
+/**
+ * FE-AI-ATLAS-CHANGE-001 to FE-AI-ATLAS-CHANGE-004
+ *
+ * Atlas changes inside the change card, drawn by the atlas views: each
+ * country or wish a row with its flag and standing, the run headed by the
+ * counters it moves. Switching a row off moves the counter at once; applied,
+ * the counter is the exact count and the receipt opens the Atlas.
+ */
+import type { AssistantApplied, AssistantChangePreview, AtlasBucketCard, AtlasCountryCard } from '@trek/shared';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
+import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
+import AssistantChangeCard from '../../AssistantChangeCard';
+import type { ChangeItem } from '../../assistantParts';
+import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
+
+const TRIP = 4;
+const INITIAL = useAssistantStore.getState();
+
+function preview(toolCallId: string, subject: string, tool: string, remove = false): AssistantChangePreview {
+ return {
+ toolCallId,
+ verbKey: remove ? 'genericRemove' : 'genericCreate',
+ subject,
+ rows: [],
+ dayId: null,
+ placeIds: [],
+ undoable: false,
+ tool,
+ domain: 'atlas',
+ };
+}
+
+function country(
+ code: string,
+ status: AtlasCountryCard['status'],
+ change: Pick, 'op' | 'after' | 'countries'>
+): AtlasCountryCard {
+ return {
+ kind: 'atlasCountry',
+ code,
+ status,
+ markedBy: null,
+ placeCount: 0,
+ tripCount: 0,
+ trips: [],
+ moreTrips: 0,
+ places: [],
+ morePlaces: 0,
+ change: { ...change, region: null, regions: null },
+ };
+}
+
+const JAPAN = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
+const KOREA = country('KR', null, { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
+const THAILAND = country('TH', 'visited', { op: 'unmark', after: 'none', countries: { before: 23, after: 22 } });
+
+function pending(id: string, subject: string, tool: string, card: ChangeItem['card'], remove = false): ChangeItem {
+ return { toolCallId: id, preview: preview(id, subject, tool, remove), applied: null, state: 'pending', card };
+}
+
+function renderCard(items: ChangeItem[], view: Partial = {}) {
+ const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
+ return render(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ useAssistantStore.setState({
+ decide: vi.fn(async () => {}),
+ byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } },
+ });
+});
+
+afterEach(() => {
+ useAssistantStore.setState(INITIAL, true);
+ vi.clearAllMocks();
+});
+
+describe('atlas changes', () => {
+ it('FE-AI-ATLAS-CHANGE-001: three country changes share one counter, and switching one off moves it at once', () => {
+ renderCard([
+ pending('c1', 'Japan', 'mark_country_visited', JAPAN),
+ pending('c2', 'South Korea', 'mark_country_visited', KOREA),
+ pending('c3', 'Thailand', 'unmark_country_visited', THAILAND, true),
+ ]);
+ expect(screen.getByText('Countries visited')).toBeInTheDocument();
+ expect(screen.getByText('23')).toBeInTheDocument();
+ expect(screen.getByText('24')).toBeInTheDocument();
+ expect(screen.getByText('+1')).toBeInTheDocument();
+ expect(screen.getAllByText('Mark the country as visited')).toHaveLength(2);
+ expect(screen.getByText('Remove a visited country')).toBeInTheDocument();
+ expect(screen.getAllByText('Atlas')).toHaveLength(3);
+ // Japan was planned, Korea not on the map, Thailand leaves it.
+ expect(screen.getByText('Planned')).toHaveClass('line-through');
+ expect(screen.getAllByText('Visited')).toHaveLength(3);
+ expect(screen.getByText('Not on your map')).toBeInTheDocument();
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Include this change' })[2] as HTMLElement);
+ expect(screen.getByText('25')).toBeInTheDocument();
+ expect(screen.getByText('+2')).toBeInTheDocument();
+ });
+
+ it('FE-AI-ATLAS-CHANGE-002: applied, the counter is the exact count and the receipt opens the Atlas', () => {
+ const onOpen = vi.fn();
+ const applied: AssistantApplied = { toolCallId: 'c1', entity: 'atlas', ids: [], dayId: null, undoUntil: null };
+ const exact = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
+ renderCard([{ ...pending('c1', 'Japan', 'mark_country_visited', exact), applied, state: 'applied' }], { onOpen });
+ expect(screen.getByText('24')).toBeInTheDocument();
+ expect(screen.queryByText('+1')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Open Atlas' }));
+ expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
+ });
+
+ it('FE-AI-ATLAS-CHANGE-003: a wish to add shows its month, its notes and that it is on the list already', () => {
+ const card: AtlasBucketCard = {
+ kind: 'atlasBucket',
+ items: [
+ {
+ id: null,
+ name: 'Northern lights',
+ countryCode: null,
+ regionCode: null,
+ targetDate: '2027-02',
+ notes: 'Glacier hike and hot springs',
+ reachedOn: null,
+ reachedFrom: null,
+ },
+ ],
+ more: 0,
+ change: { op: 'add', before: null, duplicate: true, wishes: { before: 7, after: 7 } },
+ };
+ renderCard([pending('w1', 'Northern lights', 'create_bucket_list_item', card)]);
+ expect(screen.getByText('Add to the bucket list')).toBeInTheDocument();
+ expect(screen.getByText('Feb 2027')).toBeInTheDocument();
+ expect(screen.getByText('Already on your list')).toBeInTheDocument();
+ expect(screen.getByText('Glacier hike and hot springs')).toBeInTheDocument();
+ expect(screen.getByText('Bucket list')).toBeInTheDocument();
+ expect(screen.getAllByText('7')).toHaveLength(2);
+ });
+
+ it('FE-AI-ATLAS-CHANGE-004: a wish change strikes through the date and the notes it takes away and shows a new name', () => {
+ const before = {
+ id: 5,
+ name: 'Northern lights',
+ countryCode: 'IS',
+ regionCode: null,
+ targetDate: '2027-02',
+ notes: 'Glacier hike',
+ reachedOn: null,
+ reachedFrom: null,
+ };
+ const card: AtlasBucketCard = {
+ kind: 'atlasBucket',
+ items: [{ ...before, name: 'Aurora', targetDate: null, notes: null }],
+ more: 0,
+ change: { op: 'update', before, duplicate: false, wishes: null },
+ };
+ renderCard([pending('w2', 'Aurora', 'update_bucket_list_item', card)]);
+ expect(screen.getByText('Feb 2027')).toHaveClass('line-through');
+ expect(screen.getByText('Glacier hike')).toHaveClass('line-through');
+ expect(screen.getAllByText('Removed')).toHaveLength(2);
+ expect(screen.getByText('Northern lights')).toHaveClass('line-through');
+ expect(screen.getAllByText('Aurora').length).toBeGreaterThan(0);
+ // No counter heads a wish change: the size of the list does not move.
+ expect(screen.queryByText('Bucket list')).toBeNull();
+ });
+});
diff --git a/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx b/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx
new file mode 100644
index 000000000..1a2d421e4
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx
@@ -0,0 +1,163 @@
+import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
+import { Briefcase, MapPin, Radio } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
+import CountryFlag from '../../../shared/CountryFlag';
+import type { ChangeItem } from '../../assistantParts';
+import type { CardEntry } from '../cardTypes';
+import { CardFacts } from '../parts/CardFacts';
+import CardFrame from '../parts/CardFrame';
+import { CardOpenButton } from '../parts/CardOpen';
+import { CardMore } from '../parts/CardRows';
+import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
+import { ATLAS_PAGE } from './atlasPage';
+import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
+import { useAtlasCard } from './useAtlasCard';
+
+/**
+ * One country of the atlas the way the Atlas page's country sheet shows it:
+ * the flag and the name with its standing, the continent and how many trips
+ * and places put it there, the latest trips with their dates, the places as
+ * badges, and whether a recording ticked it. In a change card the same
+ * country is the row: its flag as the lead, its standing from now to after.
+ */
+
+type Translate = ReturnType['t'];
+
+/** A trip's dates as "3 Apr 2024 to 14 Apr 2024", one day alone, or nothing. */
+function tripDates(start: string | null, end: string | null, locale: string, t: Translate): string | null {
+ if (start && end && start !== end) {
+ return t('assistant.card.atlas.dates', { from: shortDay(start, locale), to: shortDay(end, locale) });
+ }
+ const day = start ?? end;
+ return day ? shortDay(day, locale) : null;
+}
+
+function CountrySection({ card }: { card: CountryCard }) {
+ const { t } = useTranslation();
+ const { countryName, locale } = useAtlasCard();
+ const status = STATUS_LOOK[card.status ?? 'none'];
+ const trips = card.tripCount ?? 0;
+ const places = card.placeCount ?? 0;
+ return (
+
+
+
+
+
+
+ {countryName(card.code)}
+
+ }>
+ {t(status.labelKey)}
+
+
+
+
+ {t(CONTINENT_LABEL[continentOf(card.code)].labelKey)}
+
+ {trips > 0 && (
+ }>
+ {t('assistant.card.atlas.trips', { count: trips })}
+
+ )}
+ {places > 0 && (
+ }>
+ {t('assistant.card.atlas.places', { count: places })}
+
+ )}
+
+
+
+ {card.trips.length > 0 && (
+
+ {card.trips.map((trip) => (
+
+
+ {trip.title}
+
+
+ {tripDates(trip.startDate, trip.endDate, locale, t)}
+
+
+ ))}
+
+ )}
+
+ {card.places.length > 0 && (
+
+ {card.places.map((place) => (
+
+ {place}
+
+ ))}
+
+
+ )}
+ {card.markedBy === 'dawarich' && (
+
+
+ {t('assistant.card.atlas.fromRecordings')}
+
+ )}
+
+ );
+}
+
+/** The countries of an answer, one under the other in one frame. */
+export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
+ const { t } = useTranslation();
+ const countries = cards.flatMap((entry) => (entry.card.kind === 'atlasCountry' ? [entry] : []));
+ if (countries.length === 0) return null;
+ return (
+
+ {countries.map((entry, index) => (
+ 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
+ {entry.card.kind === 'atlasCountry' && }
+
+ ))}
+
+
+
+
+ );
+}
+
+/** A country change's lead: the flag with what the change does as a dot on its corner. */
+export function AtlasCountryLead({ card, item }: { card: CountryCard; item: ChangeItem }) {
+ return ;
+}
+
+/**
+ * What a country change does: the country's standing from now to after (just
+ * the new one for a country not on the map yet) and the trips that keep it
+ * there; for a region, its country and the region's own standing.
+ */
+export function AtlasCountryFacts({ card }: { card: CountryCard; item: ChangeItem }) {
+ const { t } = useTranslation();
+ const { countryName } = useAtlasCard();
+ const change = card.change;
+ if (!change) return null;
+ if (change.region) {
+ return (
+ <>
+ }>
+ {countryName(card.code)}
+
+
+ >
+ );
+ }
+ const trips = card.tripCount ?? 0;
+ return (
+ <>
+
+ {trips > 0 && (
+ }>
+ {t('assistant.card.atlas.trips', { count: trips })}
+
+ )}
+ >
+ );
+}
diff --git a/client/src/components/Assistant/cards/atlas/AtlasParts.tsx b/client/src/components/Assistant/cards/atlas/AtlasParts.tsx
new file mode 100644
index 000000000..62323b7e7
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasParts.tsx
@@ -0,0 +1,58 @@
+import type { AssistantChangeVerbKey, AtlasStatusAfter } from '@trek/shared';
+import { Star } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import CountryFlag from '../../../shared/CountryFlag';
+import type { ChangeItem } from '../../assistantParts';
+import { verbLookOf } from '../cardLooks';
+import { FactChange } from '../parts/CardFacts';
+import KindTile from '../parts/KindTile';
+import { STATUS_LOOK } from './atlasCardModel';
+
+/**
+ * The pieces the atlas cards share: a country's flag on a tile (a star for a
+ * wish without a country), with the change's verb as a dot on its corner, and
+ * a country's or a region's standing as a pill, from what it was to what it
+ * will be.
+ */
+
+export function FlagTile({
+ code,
+ verbKey = null,
+ dimmed = false,
+}: {
+ code: string | null;
+ verbKey?: AssistantChangeVerbKey | null;
+ dimmed?: boolean;
+}) {
+ const look = verbKey ? verbLookOf(verbKey) : null;
+ return (
+
+ {code ? : }
+ {look && (
+
+
+
+ )}
+
+ );
+}
+
+/** A change row's lead: the flag (a star without a country) with the change's verb, faded once it will not land. */
+export function ChangeFlag({ code, item }: { code: string | null; item: ChangeItem }) {
+ const settled = item.state === 'discarded' || item.state === 'expired';
+ return ;
+}
+
+/** A standing as a pill in its tone; the old one struck through in front when it changes. */
+export function StatusFact({ before, after }: { before: AtlasStatusAfter | null; after: AtlasStatusAfter }) {
+ const { t } = useTranslation();
+ const look = STATUS_LOOK[after];
+ const was = before && before !== after ? t(STATUS_LOOK[before].labelKey) : null;
+ return ;
+}
diff --git a/client/src/components/Assistant/cards/atlas/AtlasPassportCard.tsx b/client/src/components/Assistant/cards/atlas/AtlasPassportCard.tsx
new file mode 100644
index 000000000..ec9db7666
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasPassportCard.tsx
@@ -0,0 +1,147 @@
+import type { AssistantMessage, AtlasPassportCard } from '@trek/shared';
+import { CalendarClock, Flame, Globe2, Plane } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
+import CountryFlag from '../../../shared/CountryFlag';
+import { Tooltip } from '../../../shared/Tooltip';
+import type { CardEntry } from '../cardTypes';
+import CardFrame from '../parts/CardFrame';
+import CardHeader from '../parts/CardHeader';
+import { CardOpenButton } from '../parts/CardOpen';
+import { CardMore } from '../parts/CardRows';
+import { useCardFormat } from '../parts/useCardFormat';
+import { CONTINENT_LABEL, STATUS_LOOK } from './atlasCardModel';
+import { ATLAS_PAGE } from './atlasPage';
+import { useAtlasCard } from './useAtlasCard';
+
+/**
+ * The user's atlas as the Atlas page shows it: the visited countries as the
+ * big number with the planned ones beside it, the strip of travel figures
+ * (trips, days, cities, the distance flown), the flags latest first with the
+ * planned ones faded, the continents, the next trip and the streak. A user
+ * without trips gets the number and the flags.
+ */
+
+/** One figure of the strip, the way the Atlas page's stats read: the number big, its name small under it. */
+function Figure({ value, label }: { value: string; label: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+function Passport({ card }: { card: AtlasPassportCard }) {
+ const { t } = useTranslation();
+ const format = useCardFormat();
+ const { countryName, limits } = useAtlasCard();
+ const { countries, totals } = card;
+ const flags = card.flags.slice(0, limits.flags);
+ const moreFlags = card.flags.length - flags.length + card.moreFlags;
+ const hasTrips = totals.trips > 0;
+ const figures = [
+ { key: 'trips', value: format.count(totals.trips), label: t('atlas.trips', { count: totals.trips }) },
+ { key: 'days', value: format.count(totals.days), label: t('atlas.days', { count: totals.days }) },
+ { key: 'cities', value: format.count(totals.cities), label: t('atlas.cities', { count: totals.cities }) },
+ ...(totals.flownKm === null
+ ? []
+ : [{ key: 'flown', value: format.distance(totals.flownKm), label: t('assistant.card.atlas.factFlown') }]),
+ ];
+ const continents = card.continents.filter((continent) => continent.visited > 0);
+ const next = card.nextTrip;
+ const nextLine =
+ next && next.daysUntil !== null
+ ? next.daysUntil === 0
+ ? t('assistant.card.atlas.nextTripToday', { title: next.title })
+ : t('assistant.card.atlas.nextTrip', { title: next.title, count: next.daysUntil })
+ : null;
+ const streak = card.streakYears !== null && card.streakYears >= 2 ? card.streakYears : null;
+ return (
+
+ }
+ />
+
+
+
+ {format.count(countries.visited)}
+
+
+ {t('atlas.countries', { count: countries.visited })}
+
+
+ {countries.planned > 0 && (
+ }>
+ {t('assistant.card.atlas.planned', { count: countries.planned })}
+
+ )}
+
+ {hasTrips && (
+
+ {figures.map((figure) => (
+
+ ))}
+
+ )}
+ {flags.length > 0 && (
+
+ {flags.map((flag) => {
+ const label = `${countryName(flag.code)}, ${t(STATUS_LOOK[flag.status].labelKey)}`;
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+ {continents.length > 0 && (
+
+ {continents.map((continent) => (
+
+ {t(CONTINENT_LABEL[continent.name].labelKey)}
+ {format.count(continent.visited)}
+
+ ))}
+
+ )}
+ {(nextLine || streak !== null) && (
+
+ {nextLine && (
+
+
+ {nextLine}
+
+ )}
+ {streak !== null && (
+
+
+ {t('assistant.card.atlas.streak', { count: streak })}
+
+ )}
+
+ )}
+
+ );
+}
+
+/** The passport of an answer; a second read in the same answer shows the newer figures only. */
+export default function AtlasPassportBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
+ const passports = cards.flatMap((entry) => (entry.card.kind === 'atlasPassport' ? [entry.card] : []));
+ const latest = passports[passports.length - 1];
+ return latest ? : null;
+}
diff --git a/client/src/components/Assistant/cards/atlas/AtlasTally.tsx b/client/src/components/Assistant/cards/atlas/AtlasTally.tsx
new file mode 100644
index 000000000..7a49c1aaf
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/AtlasTally.tsx
@@ -0,0 +1,74 @@
+import { ArrowRight, Check, Globe2, MapPinned, Star } from 'lucide-react';
+
+import { useTranslation } from '../../../../i18n';
+import { SoftPill } from '../../../Planner/planParts';
+import type { ChangeItem } from '../../assistantParts';
+import { useCardFormat } from '../parts/useCardFormat';
+import { signedDelta, tallyLines, type TallyLine } from './atlasCardModel';
+import { useAtlasCard } from './useAtlasCard';
+
+/**
+ * The counters over a run of atlas changes, the way the Atlas page puts its
+ * numbers first: countries visited, the regions of a country, the bucket list,
+ * each from what it is to what it will be. Ticking a row off moves the number
+ * at once; while the changes are applied the new number shimmers, once they
+ * are, it is the exact count the writes answered.
+ */
+
+const ICONS = { countries: Globe2, regions: MapPinned, wishes: Star } as const;
+
+function TallyRow({ line }: { line: TallyLine }) {
+ const { t } = useTranslation();
+ const { countryName, locale } = useAtlasCard();
+ const Icon = ICONS[line.what];
+ const label =
+ line.what === 'countries'
+ ? t('assistant.card.atlas.tallyCountries')
+ : line.what === 'regions'
+ ? t('assistant.card.atlas.tallyRegions', { country: countryName(line.country ?? '') })
+ : t('assistant.card.atlas.tallyWishes');
+ const delta = line.after - line.before;
+ const format = useCardFormat();
+ return (
+
+
+
{label}
+
{format.count(line.before)}
+
+
+ {format.count(line.after)}
+
+ {line.phase === 'applied' ? (
+
+ ) : (
+ delta !== 0 &&
0 ? 'success' : 'danger'}>{signedDelta(delta, locale)}
+ )}
+
+ );
+}
+
+/** Heads a run of atlas changes with the counters they move; nothing under the run. */
+export default function AtlasTallyGroup({
+ items,
+ excluded,
+ place,
+}: {
+ items: readonly ChangeItem[];
+ excluded: ReadonlySet;
+ place: 'head' | 'foot';
+}) {
+ if (place !== 'head') return null;
+ const lines = tallyLines(items, excluded);
+ if (lines.length === 0) return null;
+ return (
+
+ {lines.map((line) => (
+
+ ))}
+
+ );
+}
diff --git a/client/src/components/Assistant/cards/atlas/atlasCardModel.test.ts b/client/src/components/Assistant/cards/atlas/atlasCardModel.test.ts
new file mode 100644
index 000000000..f9712213c
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/atlasCardModel.test.ts
@@ -0,0 +1,142 @@
+/**
+ * FE-AI-ATLAS-MODEL-001 to FE-AI-ATLAS-MODEL-006
+ *
+ * The pure side of the atlas cards: the counters a run of atlas changes moves
+ * as rows are ticked off, applied or discarded, a wish's month, the flags and
+ * tiles that fit, and the continent a country counts for.
+ */
+import type { AtlasBucketCard, AtlasCountryCard, TrekCard } from '@trek/shared';
+import { describe, expect, it } from 'vitest';
+
+import type { ChangeItem, ChangeState } from '../../assistantParts';
+import { continentOf, densityLimits, shortDay, signedDelta, tallyLines, wishMonth } from './atlasCardModel';
+
+function country(code: string, before: number, after: number | null, regionCode?: string): AtlasCountryCard {
+ return {
+ kind: 'atlasCountry',
+ code,
+ status: null,
+ markedBy: null,
+ placeCount: null,
+ tripCount: null,
+ trips: [],
+ moreTrips: 0,
+ places: [],
+ morePlaces: 0,
+ change: {
+ op: 'mark',
+ region: regionCode ? { code: regionCode, name: 'North', before: null, after: 'visited' } : null,
+ after: after === null ? 'unknown' : 'visited',
+ countries: { before, after },
+ regions: regionCode ? { before: 1, after: 2 } : null,
+ },
+ };
+}
+
+const wish: AtlasBucketCard = {
+ kind: 'atlasBucket',
+ items: [
+ {
+ id: null,
+ name: 'Patagonia',
+ countryCode: 'AR',
+ regionCode: null,
+ targetDate: null,
+ notes: null,
+ reachedOn: null,
+ reachedFrom: null,
+ },
+ ],
+ more: 0,
+ change: { op: 'add', before: null, duplicate: false, wishes: { before: 7, after: 8 } },
+};
+
+function item(id: string, card: TrekCard, state: ChangeState = 'pending'): ChangeItem {
+ return { toolCallId: id, preview: null, applied: null, state, card };
+}
+
+const NONE: ReadonlySet = new Set();
+
+describe('atlasCardModel', () => {
+ it('FE-AI-ATLAS-MODEL-001: waiting changes move the counter by what each ticked row adds, an unknown one by nothing', () => {
+ const items = [
+ item('a', country('JP', 23, 24)),
+ item('b', country('KR', 23, 24)),
+ item('c', country('TH', 23, null)),
+ ];
+ expect(tallyLines(items, NONE)).toEqual([
+ { key: 'countries', what: 'countries', country: null, before: 23, after: 25, phase: 'pending' },
+ ]);
+ expect(tallyLines(items, new Set(['b']))[0]).toMatchObject({ before: 23, after: 24 });
+ expect(tallyLines([item('w', wish)], NONE)).toEqual([
+ { key: 'wishes', what: 'wishes', country: null, before: 7, after: 8, phase: 'pending' },
+ ]);
+ });
+
+ it('FE-AI-ATLAS-MODEL-002: a region change adds the regions of its country; discarded or expired changes count for nothing', () => {
+ const lines = tallyLines([item('a', country('JP', 23, 24, 'JP-01'))], NONE);
+ expect(lines.map((line) => line.key)).toEqual(['countries', 'regions-JP']);
+ expect(lines[1]).toMatchObject({ what: 'regions', country: 'JP', before: 1, after: 2 });
+ const settled = [item('a', country('JP', 23, 24), 'discarded'), item('b', country('KR', 23, 24), 'expired')];
+ expect(tallyLines(settled, NONE)).toEqual([]);
+ });
+
+ it('FE-AI-ATLAS-MODEL-003: while applying the counter shimmers, once applied it shows the exact count the write answered', () => {
+ const applying = [item('a', country('JP', 23, 24), 'applying'), item('b', country('KR', 23, 24), 'applying')];
+ expect(tallyLines(applying, NONE)[0]).toMatchObject({ after: 25, phase: 'applying' });
+ const applied = [item('a', country('JP', 23, 24), 'applied'), item('b', country('KR', 24, 24), 'applied')];
+ expect(tallyLines(applied, NONE)[0]).toMatchObject({ before: 23, after: 24, phase: 'applied' });
+ });
+
+ it('FE-AI-ATLAS-MODEL-004: a wish month reads as month and year, a year alone as the year', () => {
+ expect(wishMonth('2027', 'en')).toBe('2027');
+ expect(wishMonth('2027-05', 'en')).toBe('May 2027');
+ expect(wishMonth('2027-05-14', 'de')).toBe('Mai 2027');
+ expect(wishMonth(null, 'en')).toBeNull();
+ expect(shortDay('2024-04-03', 'en')).toBe('Apr 3, 2024');
+ expect(shortDay('soon', 'en')).toBe('soon');
+ });
+
+ it('FE-AI-ATLAS-MODEL-005: more flags and tiles fit a roomy chat; a country counts for its continent', () => {
+ expect(densityLimits(false)).toEqual({ flags: 16, wishes: 6 });
+ expect(densityLimits(true)).toEqual({ flags: 24, wishes: 9 });
+ expect(continentOf('JP')).toBe('Asia');
+ expect(continentOf('ZZ')).toBe('Other');
+ expect(signedDelta(2, 'en')).toBe('+2');
+ expect(signedDelta(-1, 'en')).toBe('-1');
+ expect(signedDelta(0, 'en')).toBe('');
+ });
+
+ it('FE-AI-ATLAS-MODEL-006: a country or a region counts once however many waiting changes touch it', () => {
+ // Japan and Tokyo in one turn: one more country, one more region.
+ const japanAndTokyo = tallyLines(
+ [item('a', country('JP', 23, 24)), item('b', country('JP', 23, 24, 'JP-13'))],
+ NONE
+ );
+ expect(japanAndTokyo).toEqual([
+ { key: 'countries', what: 'countries', country: null, before: 23, after: 24, phase: 'pending' },
+ { key: 'regions-JP', what: 'regions', country: 'JP', before: 1, after: 2, phase: 'pending' },
+ ]);
+ // Two regions of a country not visited yet: one more country, two more regions.
+ const twoRegions = tallyLines(
+ [item('a', country('KR', 23, 24, 'KR-11')), item('b', country('KR', 23, 24, 'KR-26'))],
+ NONE
+ );
+ expect(twoRegions.map((line) => [line.key, line.after])).toEqual([
+ ['countries', 24],
+ ['regions-KR', 3],
+ ]);
+ // The same country twice, the same region twice: each counts once.
+ const twice = [item('a', country('JP', 23, 24)), item('b', country('JP', 23, 24))];
+ expect(tallyLines(twice, NONE)[0]?.after).toBe(24);
+ const tokyoTwice = [item('a', country('JP', 23, 24, 'JP-13')), item('b', country('JP', 23, 24, 'JP-13'))];
+ const sameRegion = tallyLines(tokyoTwice, NONE);
+ expect(sameRegion.map((line) => line.after)).toEqual([24, 2]);
+ // Switching one of the two off keeps the other's move.
+ expect(tallyLines(twice, new Set(['a']))[0]?.after).toBe(24);
+ // Once Japan landed, a waiting Tokyo adds no country on top of the exact count.
+ const tokyo = item('b', country('JP', 23, 24, 'JP-13'));
+ const landed = tallyLines([item('a', country('JP', 23, 24), 'applied'), tokyo], NONE);
+ expect(landed[0]).toMatchObject({ key: 'countries', after: 24, phase: 'applying' });
+ });
+});
diff --git a/client/src/components/Assistant/cards/atlas/atlasCardModel.ts b/client/src/components/Assistant/cards/atlas/atlasCardModel.ts
new file mode 100644
index 000000000..2ee733113
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/atlasCardModel.ts
@@ -0,0 +1,203 @@
+import {
+ ATLAS_CARD_CONTINENTS,
+ type AtlasCardContinent,
+ type AtlasStatusAfter,
+ type AtlasTally,
+ CONTINENT_MAP,
+ type TrekCard,
+} from '@trek/shared';
+import { Briefcase, CalendarClock, Check, CircleDashed, Lightbulb, type LucideIcon } from 'lucide-react';
+
+import type { PillTone } from '../../../Planner/planParts';
+import type { ChangeItem } from '../../assistantParts';
+
+/**
+ * The pure side of the atlas cards: how a status and a continent are named,
+ * how many flags and wish tiles fit the chat, a wish's month, and the counters
+ * a run of atlas changes moves (countries visited, regions of a country, the
+ * bucket list) as the user ticks rows on and off, applies or discards them.
+ */
+
+/** How a country's or a region's standing reads. A table, so each word is named where it is read. */
+export const STATUS_LOOK: Readonly> =
+ {
+ visited: { Icon: Check, tone: 'success', labelKey: 'assistant.card.atlas.status.visited' },
+ planned: { Icon: CalendarClock, tone: 'info', labelKey: 'assistant.card.atlas.status.planned' },
+ idea: { Icon: Lightbulb, tone: 'neutral', labelKey: 'assistant.card.atlas.status.idea' },
+ none: { Icon: CircleDashed, tone: 'neutral', labelKey: 'assistant.card.atlas.status.none' },
+ unknown: { Icon: Briefcase, tone: 'info', labelKey: 'assistant.card.atlas.status.unknown' },
+ };
+
+/** The continents in the Atlas page's own words. */
+export const CONTINENT_LABEL: Readonly> = {
+ Europe: { labelKey: 'atlas.europe' },
+ Asia: { labelKey: 'atlas.asia' },
+ 'North America': { labelKey: 'atlas.northAmerica' },
+ 'South America': { labelKey: 'atlas.southAmerica' },
+ Africa: { labelKey: 'atlas.africa' },
+ Oceania: { labelKey: 'atlas.oceania' },
+ Antarctica: { labelKey: 'atlas.antarctica' },
+ Other: { labelKey: 'atlas.other' },
+};
+
+/** The continent a country lies on, as the Atlas counts it. */
+export function continentOf(code: string): AtlasCardContinent {
+ const name = CONTINENT_MAP[code];
+ return (ATLAS_CARD_CONTINENTS as readonly string[]).includes(name ?? '') ? (name as AtlasCardContinent) : 'Other';
+}
+
+/** How many flags and wish tiles a card shows: more where the chat has room. */
+export function densityLimits(roomy: boolean): { flags: number; wishes: number } {
+ return roomy ? { flags: 24, wishes: 9 } : { flags: 16, wishes: 6 };
+}
+
+/** "May 2027" for a month or a day, the year alone for a year; null for none. */
+export function wishMonth(targetDate: string | null, locale: string): string | null {
+ if (!targetDate) return null;
+ const [year, month] = targetDate.split('-').map(Number);
+ if (!year) return null;
+ if (!month) return String(year);
+ try {
+ return new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' }).format(
+ new Date(Date.UTC(year, month - 1, 1))
+ );
+ } catch {
+ return targetDate;
+ }
+}
+
+/** "3 Apr 2024" from `YYYY-MM-DD`; anything else as it came. */
+export function shortDay(isoDay: string, locale: string): string {
+ const [year, month, day] = isoDay.split('-').map(Number);
+ if (!year || !month || !day) return isoDay;
+ try {
+ return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(
+ new Date(Date.UTC(year, month - 1, day))
+ );
+ } catch {
+ return isoDay;
+ }
+}
+
+/** Where a counter stands in a run of changes: waiting, being applied, applied. */
+type TallyPhase = 'pending' | 'applying' | 'applied';
+
+/** One counter a run of atlas changes moves. */
+export interface TallyLine {
+ key: string;
+ what: 'countries' | 'regions' | 'wishes';
+ /** The country of a regions counter. */
+ country: string | null;
+ before: number;
+ after: number;
+ phase: TallyPhase;
+}
+
+/** The counter one change moves, which one, and what it counts on it (a country, a region; null: the change itself). */
+interface Counted {
+ key: string;
+ what: TallyLine['what'];
+ country: string | null;
+ subject: string | null;
+ tally: AtlasTally;
+}
+
+/** The counters one card moves. */
+function countedOf(card: TrekCard): Counted[] {
+ if (card.kind === 'atlasCountry' && card.change) {
+ const { countries, regions, region } = card.change;
+ const counted: Counted[] = [];
+ if (countries) {
+ counted.push({ key: 'countries', what: 'countries', country: null, subject: card.code, tally: countries });
+ }
+ if (regions && region) {
+ const key = `regions-${card.code}`;
+ counted.push({ key, what: 'regions', country: card.code, subject: region.code, tally: regions });
+ }
+ return counted;
+ }
+ if (card.kind === 'atlasBucket' && card.change?.wishes) {
+ return [{ key: 'wishes', what: 'wishes', country: null, subject: null, tally: card.change.wishes }];
+ }
+ return [];
+}
+
+/** A change that will not land: discarded, expired or failed. */
+function settled(item: ChangeItem): boolean {
+ return item.state === 'discarded' || item.state === 'expired' || item.state === 'failed';
+}
+
+/** One counter while a run is read: the exact count a write answered, what each subject still moves, what landed. */
+interface Building {
+ line: TallyLine;
+ exact: number | null;
+ moves: Map;
+ landed: Set;
+}
+
+/** What the waiting moves add up to: each country or region once, by one at most either way, none that landed. */
+function movedBy({ moves, landed }: Building): number {
+ let moved = 0;
+ for (const [subject, delta] of moves) {
+ if (!landed.has(subject)) moved += Math.max(-1, Math.min(1, delta));
+ }
+ return moved;
+}
+
+/**
+ * The counters a run of atlas changes moves. Waiting, a counter starts where
+ * the first change found it and moves by what the changes still ticked on do
+ * (a change whose outcome the trips decide moves it by nothing until it is
+ * applied). Every change was predicted from the same reading, so a country or
+ * a region counts once: marking Japan and Tokyo, or Japan twice, is one more
+ * country. Applied, it shows the last exact count a write answered, and a
+ * country or region that landed moves it no further. A counter whose changes
+ * were all discarded or expired is left out.
+ */
+export function tallyLines(items: readonly ChangeItem[], excluded: ReadonlySet): TallyLine[] {
+ const built = new Map();
+ for (const item of items) {
+ if (!item.card || settled(item)) continue;
+ for (const counted of countedOf(item.card)) {
+ const entry = built.get(counted.key) ?? {
+ line: {
+ key: counted.key,
+ what: counted.what,
+ country: counted.country,
+ before: counted.tally.before,
+ after: counted.tally.before,
+ phase: 'pending' as TallyPhase,
+ },
+ exact: null,
+ moves: new Map(),
+ landed: new Set(),
+ };
+ const subject = counted.subject ?? item.toolCallId;
+ if (item.state === 'applied') {
+ if (counted.tally.after !== null) entry.exact = counted.tally.after;
+ entry.landed.add(subject);
+ if (entry.line.phase !== 'applying') entry.line.phase = 'applied';
+ } else {
+ if (item.state === 'applying' || entry.line.phase === 'applied') entry.line.phase = 'applying';
+ if (!excluded.has(item.toolCallId) && counted.tally.after !== null) {
+ entry.moves.set(subject, (entry.moves.get(subject) ?? 0) + counted.tally.after - counted.tally.before);
+ }
+ }
+ built.set(counted.key, entry);
+ }
+ }
+ return [...built.values()].map((entry) => ({
+ ...entry.line,
+ after: Math.max(0, (entry.exact ?? entry.line.before) + movedBy(entry)),
+ }));
+}
+
+/** "+2" or "-1" for what a counter moved by; empty when it did not move. */
+export function signedDelta(delta: number, locale: string): string {
+ if (delta === 0) return '';
+ try {
+ return new Intl.NumberFormat(locale, { signDisplay: 'exceptZero' }).format(delta);
+ } catch {
+ return delta > 0 ? `+${delta}` : String(delta);
+ }
+}
diff --git a/client/src/components/Assistant/cards/atlas/atlasLook.ts b/client/src/components/Assistant/cards/atlas/atlasLook.ts
index 48494a346..6d0468a3f 100644
--- a/client/src/components/Assistant/cards/atlas/atlasLook.ts
+++ b/client/src/components/Assistant/cards/atlas/atlasLook.ts
@@ -1,20 +1,58 @@
+import { Globe2 } from 'lucide-react';
+
import type { PackLook } from '../cardTypes';
+import { ATLAS_PAGE } from './atlasPage';
/**
- * What the atlas brings to the chat's pure look: the verbs, tool names and
- * steps it adds, what an applied change of its own opens, and the look of each
- * of its kinds. Empty until the atlas cards fill it. A look stays pure, icons
- * and literals only: the chat's main chunk reads every look, so whatever a look
- * imports ships with it.
+ * What the atlas brings to the chat's pure look: the names of its tools and
+ * reading steps, what an applied atlas change opens (the Atlas page: a
+ * country, a region or a wish has no place on the trip's map), and what to
+ * ask after each of its cards. A look stays pure, icons and literals only:
+ * the chat's main chunk reads every look, so whatever a look imports ships
+ * with it. `mark_country_visited` keeps its core label.
*/
export const ATLAS_LOOK = {
verbs: {},
- tools: {},
- steps: {},
- entities: {},
+ tools: {
+ unmark_country_visited: { labelKey: 'assistant.tool.unmark_country_visited' },
+ mark_region_visited: { labelKey: 'assistant.tool.mark_region_visited' },
+ unmark_region_visited: { labelKey: 'assistant.tool.unmark_region_visited' },
+ create_bucket_list_item: { labelKey: 'assistant.tool.create_bucket_list_item' },
+ update_bucket_list_item: { labelKey: 'assistant.tool.update_bucket_list_item' },
+ delete_bucket_list_item: { labelKey: 'assistant.tool.delete_bucket_list_item' },
+ },
+ steps: {
+ get_atlas_stats: { stepKey: 'assistant.step.atlas' },
+ get_country_atlas_places: { stepKey: 'assistant.step.atlasCountry' },
+ list_visited_regions: { stepKey: 'assistant.step.regions' },
+ locate_atlas_region: { stepKey: 'assistant.step.locateRegion' },
+ list_bucket_list: { stepKey: 'assistant.step.bucketList' },
+ },
+ entities: {
+ atlas: {
+ Icon: Globe2,
+ openLabelKey: 'assistant.card.atlas.open',
+ open: () => ATLAS_PAGE,
+ },
+ },
cards: {
- atlasPassport: {},
- atlasCountry: {},
- atlasBucket: {},
+ atlasPassport: {
+ followUps: [
+ { id: 'atlasWishes', labelKey: 'assistant.followUp.atlasWishes' },
+ { id: 'atlasLeast', labelKey: 'assistant.followUp.atlasLeast' },
+ ],
+ },
+ atlasCountry: {
+ followUps: [
+ { id: 'atlasMark', labelKey: 'assistant.followUp.atlasMark' },
+ { id: 'atlasWish', labelKey: 'assistant.followUp.atlasWish' },
+ ],
+ },
+ atlasBucket: {
+ followUps: [
+ { id: 'atlasPlanWish', labelKey: 'assistant.followUp.atlasPlanWish' },
+ { id: 'atlasAddWish', labelKey: 'assistant.followUp.atlasAddWish' },
+ ],
+ },
},
} satisfies PackLook<'atlasPassport' | 'atlasCountry' | 'atlasBucket'>;
diff --git a/client/src/components/Assistant/cards/atlas/atlasPage.ts b/client/src/components/Assistant/cards/atlas/atlasPage.ts
new file mode 100644
index 000000000..56fcceb6c
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/atlasPage.ts
@@ -0,0 +1,8 @@
+import type { EntityTarget } from '../cardTypes';
+
+/**
+ * Where every atlas card leads: the Atlas page. A file of its own, so the
+ * pure look names it without pulling the card model (continents, icons) into
+ * the chat's main chunk.
+ */
+export const ATLAS_PAGE: EntityTarget = { kind: 'page', path: '/atlas' };
diff --git a/client/src/components/Assistant/cards/atlas/atlasViews.tsx b/client/src/components/Assistant/cards/atlas/atlasViews.tsx
index 1d5551503..9941f1235 100644
--- a/client/src/components/Assistant/cards/atlas/atlasViews.tsx
+++ b/client/src/components/Assistant/cards/atlas/atlasViews.tsx
@@ -1,14 +1,31 @@
-import { type CardView, PENDING_VIEW } from '../cardTypes';
+import type { CardView } from '../cardTypes';
+import AtlasBucketBlock, { AtlasBucketBody, AtlasBucketFacts, AtlasBucketLead } from './AtlasBucketCard';
+import AtlasCountryBlock, { AtlasCountryFacts, AtlasCountryLead } from './AtlasCountryCard';
+import AtlasPassportBlock from './AtlasPassportCard';
+import AtlasTallyGroup from './AtlasTally';
/**
- * How the kinds of the atlas draw in the chat; each one draws nothing until its
- * cards are built. This file only maps each kind to its view: the components
- * live in sibling files, since a file that exports anything besides components
- * breaks fast refresh, and lint:warnings holds
+ * How the kinds of the atlas draw in the chat: the passport and the countries
+ * of an answer, the bucket list as wish tiles, and inside a change card each
+ * country, region or wish as a row with its flag, headed by the counters the
+ * run of changes moves. This file only maps each kind to its view: the
+ * components live in sibling files, since a file that exports anything besides
+ * components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const ATLAS_VIEWS = {
- atlasPassport: PENDING_VIEW,
- atlasCountry: PENDING_VIEW,
- atlasBucket: PENDING_VIEW,
+ atlasPassport: { Block: AtlasPassportBlock },
+ atlasCountry: {
+ Block: AtlasCountryBlock,
+ Lead: AtlasCountryLead,
+ Facts: AtlasCountryFacts,
+ Group: AtlasTallyGroup,
+ },
+ atlasBucket: {
+ Block: AtlasBucketBlock,
+ Lead: AtlasBucketLead,
+ Facts: AtlasBucketFacts,
+ Body: AtlasBucketBody,
+ Group: AtlasTallyGroup,
+ },
} satisfies { [K in 'atlasPassport' | 'atlasCountry' | 'atlasBucket']: CardView };
diff --git a/client/src/components/Assistant/cards/atlas/useAtlasCard.ts b/client/src/components/Assistant/cards/atlas/useAtlasCard.ts
new file mode 100644
index 000000000..a0f747eeb
--- /dev/null
+++ b/client/src/components/Assistant/cards/atlas/useAtlasCard.ts
@@ -0,0 +1,19 @@
+import { useMemo } from 'react';
+
+import { useTranslation } from '../../../../i18n';
+import { countryNamer } from '../../../../utils/countryName';
+import { useAssistantView } from '../../assistantViewContext';
+import { densityLimits } from './atlasCardModel';
+
+/**
+ * What every atlas card reads from where it is drawn: country names in the
+ * reader's language, the locale for dates and numbers, and how many flags and
+ * wish tiles fit. The phone always draws the compact card, a narrow window too.
+ */
+export function useAtlasCard() {
+ const { language, locale } = useTranslation();
+ const { variant, density } = useAssistantView();
+ const roomy = variant !== 'fullscreen' && density === 'roomy';
+ const countryName = useMemo(() => countryNamer(language), [language]);
+ return { countryName, locale, roomy, limits: densityLimits(roomy) };
+}
diff --git a/client/src/utils/countryName.test.ts b/client/src/utils/countryName.test.ts
new file mode 100644
index 000000000..3354af93c
--- /dev/null
+++ b/client/src/utils/countryName.test.ts
@@ -0,0 +1,21 @@
+// FE-UTIL-COUNTRYNAME-001 to FE-UTIL-COUNTRYNAME-002
+import { describe, expect, it } from 'vitest';
+
+import { countryNamer } from './countryName';
+
+const countryName = (code: string, language: string) => countryNamer(language)(code);
+
+describe('countryName', () => {
+ it('FE-UTIL-COUNTRYNAME-001: a code reads as the country in the reader language, whatever its case', () => {
+ expect(countryName('JP', 'en')).toBe('Japan');
+ expect(countryName('fr', 'de')).toBe('Frankreich');
+ const name = countryNamer('es');
+ expect(name('DE')).toBe('Alemania');
+ });
+
+ it('FE-UTIL-COUNTRYNAME-002: something that is no country code stays as it came', () => {
+ expect(countryName('JPN', 'en')).toBe('JPN');
+ expect(countryName('', 'en')).toBe('');
+ expect(countryName('JP', 'not-a-language')).toBe('Japan');
+ });
+});
diff --git a/client/src/utils/countryName.ts b/client/src/utils/countryName.ts
new file mode 100644
index 000000000..be06edd53
--- /dev/null
+++ b/client/src/utils/countryName.ts
@@ -0,0 +1,25 @@
+import { getIntlLanguage } from '@trek/shared';
+
+/**
+ * Country names in the reader's language, from ISO alpha-2 codes. A namer is
+ * built once per language (the caller keeps it, a component in a memo) and
+ * answers the code itself where the runtime knows no name or the code is
+ * none, so a card never shows an empty label.
+ */
+export function countryNamer(language: string): (code: string) => string {
+ let names: Intl.DisplayNames | null = null;
+ try {
+ names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' });
+ } catch {
+ names = null;
+ }
+ return (code) => {
+ const upper = code.toUpperCase();
+ if (!names || !/^[A-Z]{2}$/.test(upper)) return code;
+ try {
+ return names.of(upper) ?? code;
+ } catch {
+ return code;
+ }
+ };
+}
diff --git a/server/src/nest/assistant/cards/atlas/atlas-answers.ts b/server/src/nest/assistant/cards/atlas/atlas-answers.ts
new file mode 100644
index 000000000..7d394b8a7
--- /dev/null
+++ b/server/src/nest/assistant/cards/atlas/atlas-answers.ts
@@ -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),
+};
diff --git a/server/src/nest/assistant/cards/atlas/atlas-applied.ts b/server/src/nest/assistant/cards/atlas/atlas-applied.ts
new file mode 100644
index 000000000..4c5107dd5
--- /dev/null
+++ b/server/src/nest/assistant/cards/atlas/atlas-applied.ts
@@ -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 = {
+ tool: 'delete_bucket_list_item',
+ args: (_args, result) => {
+ const itemId = idIn(result, 'item');
+ return itemId ? { itemId } : null;
+ },
+ reads: () => [BUCKET_READ],
+ unchanged: wishUnchanged,
+};
diff --git a/server/src/nest/assistant/cards/atlas/atlas-changes.ts b/server/src/nest/assistant/cards/atlas/atlas-changes.ts
new file mode 100644
index 000000000..278d69950
--- /dev/null
+++ b/server/src/nest/assistant/cards/atlas/atlas-changes.ts
@@ -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 | null;
+ bucket: BucketSnapshot | null;
+}
+
+const NOTHING: AtlasLoaded = { atlas: null, regions: null, bucket: null };
+
+export async function loadAtlas(_args: ToolArgs, read: CardReader): Promise {
+ return { ...NOTHING, atlas: atlasSnapshotOf(await read('get_atlas_stats')) };
+}
+
+export async function loadAtlasRegions(_args: ToolArgs, read: CardReader): Promise {
+ 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 {
+ 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, 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 {
+ 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>;
+
+/** 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>;
diff --git a/server/src/nest/assistant/cards/atlas/atlas-values.ts b/server/src/nest/assistant/cards/atlas/atlas-values.ts
new file mode 100644
index 000000000..6f4b9db01
--- /dev/null
+++ b/server/src/nest/assistant/cards/atlas/atlas-values.ts
@@ -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;
+}
+
+/** `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();
+ 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 | null {
+ const grouped = recordOf(raw).regions;
+ if (!grouped || typeof grouped !== 'object') return null;
+ const regions = new Map();
+ 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, 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;
+}
+
+/** `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();
+ 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 };
+}
diff --git a/server/src/nest/assistant/cards/atlas/index.ts b/server/src/nest/assistant/cards/atlas/index.ts
index 7ce009986..f2d08357d 100644
--- a/server/src/nest/assistant/cards/atlas/index.ts
+++ b/server/src/nest/assistant/cards/atlas/index.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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'],
+};
diff --git a/server/src/nest/atlas/atlas-standing.helpers.ts b/server/src/nest/atlas/atlas-standing.helpers.ts
new file mode 100644
index 000000000..16b529040
--- /dev/null
+++ b/server/src/nest/atlas/atlas-standing.helpers.ts
@@ -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,
+ userId: number,
+ code: string,
+): Promise {
+ 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,
+ userId: number,
+ countryCode: string,
+): Promise {
+ 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(read: () => Promise): Promise {
+ 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;
+ }
+}
diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts
index db34882fe..b1acebf2c 100644
--- a/server/src/nest/atlas/atlas.mcp.ts
+++ b/server/src/nest/atlas/atlas.mcp.ts
@@ -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, 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({
diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts
index 0bc37c464..bd9b697cc 100644
--- a/server/tests/unit/mcp/tools-atlas-expanded.test.ts
+++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts
@@ -268,7 +268,11 @@ describe('Tool: list_visited_regions', () => {
await withHarness(user.id, async (h) => {
const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } });
- expect(parseToolResult(unmark)).toEqual({ success: true });
+ // The places of the trip keep Italy on the map; only the region is gone.
+ expect(parseToolResult(unmark)).toEqual({
+ success: true,
+ after: { country_status: 'visited', countries_visited: 1, regions_visited: 0 },
+ });
const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' });
expect(tombstone).toBeTruthy();
@@ -369,7 +373,7 @@ describe('Tool: mark_region_visited', () => {
name: 'unmark_region_visited',
arguments: { regionCode: 'jp-13' },
});
- expect(parseToolResult(unmark)).toEqual({ success: true });
+ expect(parseToolResult(unmark)).toMatchObject({ success: true });
expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull();
});
});
@@ -388,6 +392,8 @@ describe('Tool: mark_region_visited', () => {
expect(data.region.name).toBe('California');
expect(data.region.country_code).toBe('US');
expect(data.region.manuallyMarked).toBe(true);
+ // Where the atlas stands now, for the assistant's card: the region brought its country along.
+ expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1, regions_visited: 1 });
const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' });
expect(row).toBeTruthy();
});
@@ -419,8 +425,11 @@ describe('Tool: unmark_region_visited', () => {
name: 'unmark_region_visited',
arguments: { regionCode: 'IT-LO' },
});
- const data = parseToolResult(result) as any;
- expect(data.success).toBe(true);
+ // The last region of a country it was never marked for takes the country along.
+ expect(parseToolResult(result)).toEqual({
+ success: true,
+ after: { country_status: null, countries_visited: 0, regions_visited: 0 },
+ });
expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull();
});
});
diff --git a/server/tests/unit/mcp/tools-atlas.test.ts b/server/tests/unit/mcp/tools-atlas.test.ts
index 5d1a177e7..ba32543e3 100644
--- a/server/tests/unit/mcp/tools-atlas.test.ts
+++ b/server/tests/unit/mcp/tools-atlas.test.ts
@@ -1,10 +1,12 @@
/**
* Unit tests for MCP atlas and bucket list tools:
- * mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item.
+ * mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item,
+ * list_bucket_list.
*/
import { db as testDb } from '../../../src/db/database';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
+import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { countRows, findRow } from '../../helpers/factories/rows';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
@@ -55,11 +57,28 @@ describe('Tool: mark_country_visited', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.country_code).toBe('FR');
+ // Where the atlas stands now, which the assistant's card and answer read.
+ expect(data.after).toEqual({ country_status: 'visited', countries_visited: 1 });
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'FR' });
expect(row).toBeTruthy();
});
});
+ it('MCP-ATLAS-AFTER-001: a failed read after the mark leaves `after` out, not the done write', async () => {
+ const { user } = createUser(testDb);
+ const stats = vi.spyOn(AtlasService.prototype, 'stats').mockRejectedValue(new Error('stats unavailable'));
+ try {
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'NO' } });
+ expect(result.isError).toBeFalsy();
+ expect(parseToolResult(result)).toEqual({ success: true, country_code: 'NO' });
+ expect(await findRow(orm, VisitedCountries, { user: user.id, country_code: 'NO' })).toBeTruthy();
+ });
+ } finally {
+ stats.mockRestore();
+ }
+ });
+
it('is idempotent — marking twice does not error', async () => {
const { user } = createUser(testDb);
createVisitedCountry(testDb, user.id, 'JP');
@@ -92,8 +111,11 @@ describe('Tool: unmark_country_visited', () => {
createVisitedCountry(testDb, user.id, 'ES');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
- const data = parseToolResult(result) as any;
- expect(data.success).toBe(true);
+ expect(parseToolResult(result)).toEqual({
+ success: true,
+ country_code: 'ES',
+ after: { country_status: null, countries_visited: 0 },
+ });
const row = await findRow(orm, VisitedCountries, { user: user.id, country_code: 'ES' });
expect(row).toBeNull();
});
@@ -250,3 +272,32 @@ describe('Tool: delete_bucket_list_item', () => {
});
});
});
+
+// ---------------------------------------------------------------------------
+// list_bucket_list
+// ---------------------------------------------------------------------------
+
+describe('Tool: list_bucket_list', () => {
+ it("MCP-ATLAS-BUCKET-001: answers the user's own wishes with their ids, shaped like GET /bucket-list", async () => {
+ const { user } = createUser(testDb);
+ const { user: other } = createUser(testDb);
+ const kyoto = createBucketListItem(testDb, user.id, { name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
+ createBucketListItem(testDb, other.id, { name: "Other's Wish" });
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
+ const data = parseToolResult(result) as { items: Array> };
+ expect(data.items).toHaveLength(1);
+ expect(data.items[0]).toMatchObject({ id: kyoto.id, name: 'Kyoto', country_code: 'JP', notes: 'In autumn' });
+ });
+ });
+
+ it('MCP-ATLAS-BUCKET-002: answers an empty list, and reads for a demo user too', async () => {
+ process.env.DEMO_MODE = 'true';
+ const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+ await withHarness(user.id, async (h) => {
+ const result = await h.client.callTool({ name: 'list_bucket_list', arguments: {} });
+ expect(result.isError).toBeFalsy();
+ expect(parseToolResult(result)).toEqual({ items: [] });
+ });
+ });
+});
diff --git a/server/tests/unit/nest/assistant/cards/atlas/atlas-cards.test.ts b/server/tests/unit/nest/assistant/cards/atlas/atlas-cards.test.ts
new file mode 100644
index 000000000..57cc3d68e
--- /dev/null
+++ b/server/tests/unit/nest/assistant/cards/atlas/atlas-cards.test.ts
@@ -0,0 +1,519 @@
+/**
+ * The atlas cards: the passport of get_atlas_stats, one country of
+ * get_country_atlas_places, the wishes of list_bucket_list, the change cards
+ * of the seven atlas writes predicted from the turn's reads, the applied
+ * cards built from what the writes answer, and the way back from an added
+ * wish. Every card is checked against the card union and holds no address.
+ */
+import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
+import { bucketListOf, countryOf, passportOf } from '../../../../../../src/nest/assistant/cards/atlas/atlas-answers';
+import { DELETE_ADDED_WISH } from '../../../../../../src/nest/assistant/cards/atlas/atlas-applied';
+import {
+ ATLAS_CHANGES,
+ countryNameIn,
+ loadAtlas,
+ loadAtlasRegions,
+ loadBucket,
+ type AtlasLoaded,
+} from '../../../../../../src/nest/assistant/cards/atlas/atlas-changes';
+import {
+ atlasSnapshotOf,
+ bucketSnapshotOf,
+ regionsSnapshotOf,
+} from '../../../../../../src/nest/assistant/cards/atlas/atlas-values';
+import type { CardReader, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
+import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
+import {
+ answerCardOf,
+ appliedCardOf,
+ ASSISTANT_UNDO_MAP,
+ ASSISTANT_WRITE_TOOLS,
+ CARD_PROMPT_LINES,
+ TOOL_CARDS,
+} from '../../../../../../src/nest/assistant/cards/registry';
+import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
+import { assistantChangePreviewSchema, trekCardSchema, type TrekCard } from '@trek/shared';
+
+import { describe, expect, it, vi } from 'vitest';
+
+const card = buildTripCard(1, 7, { trip: { title: 'Alps' } }, null, null);
+const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: '2026-10-22', airport: () => null };
+
+/** The card parses as a card of the union and carries no address. */
+function valid(built: TrekCard | null): TrekCard {
+ expect(built).not.toBeNull();
+ expect(trekCardSchema.safeParse(built).success).toBe(true);
+ expect(JSON.stringify(built)).not.toMatch(/https?:/);
+ return built as TrekCard;
+}
+
+/** A country as `stats()` answers it. */
+function visit(code: string, places: number, first: string | null, last: string | null, status: string) {
+ return { code, placeCount: places, tripCount: places > 0 ? 1 : 0, firstVisit: first, lastVisit: last, status };
+}
+
+const STATS = {
+ stats: {
+ countries: [
+ visit('FR', 4, '2019-05-01', '2019-05-09', 'visited'),
+ visit('JP', 14, '2019-10-12', '2024-04-14', 'visited'),
+ visit('DE', 0, null, null, 'visited'),
+ visit('PT', 3, '2026-11-03', null, 'planned'),
+ visit('IS', 1, null, null, 'idea'),
+ ],
+ stats: {
+ totalTrips: 3,
+ totalPlaces: 22,
+ totalCountries: 3,
+ totalCountriesPlanned: 1,
+ totalCountriesIdea: 1,
+ totalDays: 30,
+ totalCities: 9,
+ },
+ continents: { Europe: 2, Asia: 1, Atlantis: 4 },
+ continentsPlanned: { Europe: 1 },
+ nextTrip: { id: 3, title: 'Lisbon', start_date: '2026-11-03', daysUntil: 12 },
+ streak: 3,
+ },
+ travel: { countries: ['FR', 'JP', 'DE'], cities: ['paris'], totalDistanceKm: 18_230.6 },
+};
+
+const REGIONS = {
+ regions: {
+ JP: [
+ { code: 'JP-26', name: 'Kyoto', placeCount: 6, status: 'visited' },
+ { code: 'JP-13', name: 'Tokyo', placeCount: 8, status: 'visited' },
+ ],
+ DE: [{ code: 'DE-BY', name: 'Bavaria', placeCount: 0, status: 'visited', manuallyMarked: true }],
+ },
+};
+
+const WISH = {
+ id: 5,
+ user_id: 1,
+ name: 'Northern lights',
+ lat: 64.1,
+ lng: -21.9,
+ country_code: 'IS',
+ notes: 'Glacier hike',
+ created_at: '2026-01-01 10:00:00',
+ target_date: '2027-02',
+ visited_at: null,
+ visited_source: null,
+ region_code: null,
+};
+const PETRA = { ...WISH, id: 8, name: 'Petra', country_code: 'JO' };
+const BUCKET = {
+ items: [
+ WISH,
+ { ...WISH, id: 6, name: 'Kyoto in autumn', country_code: 'JP', lat: null, lng: null, target_date: null },
+ { ...WISH, id: 7, name: 'Machu Picchu', country_code: 'PER', target_date: '2026-05', notes: null },
+ { ...PETRA, visited_at: '2025-04-02 08:00:00', visited_source: 'dawarich' },
+ ],
+};
+
+const loaded: AtlasLoaded = {
+ atlas: atlasSnapshotOf(STATS),
+ regions: regionsSnapshotOf(REGIONS),
+ bucket: bucketSnapshotOf(BUCKET),
+};
+
+describe('atlas answer cards', () => {
+ it('ASTATLAS-001: the passport takes its figures, flags in the Atlas order, known continents and the flown distance', () => {
+ const passport = valid(passportOf(STATS));
+ expect(passport).toMatchObject({
+ kind: 'atlasPassport',
+ countries: { visited: 3, planned: 1, idea: 1 },
+ totals: { trips: 3, days: 30, places: 22, cities: 9, flownKm: 18_231 },
+ moreFlags: 0,
+ nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
+ streakYears: 3,
+ });
+ // Visited latest first, the one without a date last, then planned; an idea is left out.
+ expect(passport.kind === 'atlasPassport' && passport.flags).toEqual([
+ { code: 'JP', status: 'visited' },
+ { code: 'FR', status: 'visited' },
+ { code: 'DE', status: 'visited' },
+ { code: 'PT', status: 'planned' },
+ ]);
+ expect(passport.kind === 'atlasPassport' && passport.continents).toEqual([
+ { name: 'Europe', visited: 2, planned: 1 },
+ { name: 'Asia', visited: 1, planned: 0 },
+ ]);
+ });
+
+ it('ASTATLAS-002: a user without trips gets the short form: no planned or idea counts, cities from the passport, nothing flown', () => {
+ const passport = valid(
+ passportOf({
+ stats: {
+ countries: [{ code: 'NO', placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
+ trips: [],
+ stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 },
+ },
+ travel: { cities: ['oslo', 'bergen'], totalDistanceKm: 0 },
+ }),
+ );
+ expect(passport).toMatchObject({
+ countries: { visited: 1, planned: 0, idea: 0 },
+ totals: { cities: 2, flownKm: null },
+ flags: [{ code: 'NO', status: 'visited' }],
+ continents: [],
+ nextTrip: null,
+ streakYears: null,
+ });
+ expect(passportOf({ stats: 'nope' })).toBeNull();
+ });
+
+ it('ASTATLAS-003: forty flags at most, the rest counted', () => {
+ const countries = Array.from({ length: 45 }, (_, i) => ({
+ code: `${String.fromCharCode(65 + Math.floor(i / 26))}${String.fromCharCode(65 + (i % 26))}`,
+ status: 'visited',
+ }));
+ const passport = valid(passportOf({ stats: { ...STATS.stats, countries }, travel: STATS.travel }));
+ expect(passport.kind === 'atlasPassport' && passport.flags).toHaveLength(40);
+ expect(passport).toMatchObject({ moreFlags: 5 });
+ });
+
+ it('ASTATLAS-004: a country reads its trips latest first and its places in their order, masked and cut', () => {
+ const trips = [
+ { id: 1, title: 'Tokyo food week', start_date: '2019-10-12', end_date: '2019-10-19' },
+ { id: 2, title: 'Kyoto with ana@example.com', start_date: '2024-04-03', end_date: '2024-04-14' },
+ ...[3, 4, 5].map((id) => ({ id, title: `Trip ${id}`, start_date: null, end_date: null })),
+ ];
+ const places = [
+ { id: 10, name: 'Tsukiji', trip_id: 1 },
+ { id: 11, name: 'Fushimi Inari', trip_id: 2 },
+ ...Array.from({ length: 9 }, (_, i) => ({ id: 20 + i, name: `Spot ${i}`, trip_id: 2 })),
+ ];
+ const raw = { places, trips, manually_marked: false, marked_source: null, status: 'visited' };
+ const country = valid(countryOf({ countryCode: 'jp' }, raw));
+ expect(country).toMatchObject({
+ kind: 'atlasCountry',
+ code: 'JP',
+ status: 'visited',
+ markedBy: null,
+ placeCount: 11,
+ tripCount: 5,
+ moreTrips: 1,
+ morePlaces: 3,
+ change: null,
+ });
+ if (country.kind !== 'atlasCountry') throw new Error('not a country');
+ expect(country.trips.map((trip) => trip.id)).toEqual([2, 1, 3, 4]);
+ expect(country.trips[0]?.title).not.toContain('ana@example.com');
+ expect(country.places.slice(0, 2)).toEqual(['Fushimi Inari', 'Spot 0']);
+ expect(country.places).not.toContain('Tsukiji');
+ });
+
+ it('ASTATLAS-005: a country nothing put on the map says so, one marked from recordings names it, a bad code gives none', () => {
+ const empty = { places: [], trips: [], manually_marked: false, status: 'idea' };
+ expect(countryOf({ countryCode: 'PE' }, empty)).toMatchObject({ status: null, placeCount: 0 });
+ const recorded = { ...empty, manually_marked: true, marked_source: 'dawarich', status: 'visited' };
+ const norway = valid(countryOf({ countryCode: 'NO' }, recorded));
+ expect(norway).toMatchObject({ status: 'visited', markedBy: 'dawarich' });
+ expect(countryOf({ countryCode: 'NOR' }, {})).toBeNull();
+ expect(countryOf({}, {})).toBeNull();
+ });
+
+ it('ASTATLAS-006: the bucket list shows open wishes soonest first, then the reached ones, without coordinates', () => {
+ const bucket = valid(bucketListOf(BUCKET));
+ if (bucket.kind !== 'atlasBucket') throw new Error('not a bucket');
+ expect(bucket.items.map((item) => item.id)).toEqual([7, 5, 6, 8]);
+ expect(bucket.items[0]).toMatchObject({ countryCode: null, targetDate: '2026-05', notes: null });
+ expect(bucket.items[3]).toMatchObject({ reachedOn: '2025-04-02', reachedFrom: 'dawarich' });
+ expect(JSON.stringify(bucket)).not.toMatch(/lat|lng|user_id|64\.1/);
+ const many = { items: Array.from({ length: 15 }, (_, i) => ({ ...WISH, id: i + 1, name: `Wish ${i}` })) };
+ expect(bucketListOf(many)).toMatchObject({ more: 3 });
+ expect(bucketListOf({ items: [] })).toBeNull();
+ expect(bucketListOf('That is not a list')).toBeNull();
+ });
+});
+
+describe('atlas change cards', () => {
+ it('ASTATLAS-007: marking a country adds one unless it is visited already; an unread atlas leaves the counter out', () => {
+ const peru = valid(ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'pe' }, loaded));
+ expect(peru).toMatchObject({
+ code: 'PE',
+ status: null,
+ change: { op: 'mark', after: 'visited', countries: { before: 3, after: 4 } },
+ });
+ const portugal = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'PT' }, loaded);
+ expect(portugal).toMatchObject({ status: 'planned', placeCount: 3, change: { countries: { after: 4 } } });
+ const france = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, loaded);
+ expect(france).toMatchObject({ change: { countries: { before: 3, after: 3 } } });
+ const blind = ATLAS_CHANGES.mark_country_visited(ctx, { country_code: 'FR' }, null);
+ expect(valid(blind)).toMatchObject({ status: null, change: { countries: null } });
+ expect(ATLAS_CHANGES.mark_country_visited(ctx, {}, loaded)).toBeNull();
+ });
+
+ it('ASTATLAS-008: an unmark takes a country off the map unless its trips keep it, where the trips decide', () => {
+ expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'DE' }, loaded)).toMatchObject({
+ change: { op: 'unmark', after: 'none', countries: { before: 3, after: 2 } },
+ });
+ expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'JP' }, loaded)).toMatchObject({
+ change: { after: 'unknown', countries: { before: 3, after: null } },
+ });
+ expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'PT' }, loaded)).toMatchObject({
+ change: { after: 'planned', countries: { before: 3, after: 3 } },
+ });
+ expect(ATLAS_CHANGES.unmark_country_visited(ctx, { country_code: 'AU' }, loaded)).toMatchObject({
+ status: null,
+ change: { after: 'none', countries: { before: 3, after: 3 } },
+ });
+ });
+
+ it('ASTATLAS-009: a region mark counts the regions of its country and moves the country like a mark', () => {
+ const osaka = valid(
+ ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'jp-27', regionName: 'Osaka', countryCode: 'jp' }, loaded),
+ );
+ expect(osaka).toMatchObject({
+ code: 'JP',
+ change: {
+ op: 'mark',
+ region: { code: 'JP-27', name: 'Osaka', before: null, after: 'visited' },
+ countries: { before: 3, after: 3 },
+ regions: { before: 2, after: 3 },
+ },
+ });
+ const kyoto = { regionCode: 'JP-26', regionName: 'Kyoto', countryCode: 'JP' };
+ const again = ATLAS_CHANGES.mark_region_visited(ctx, kyoto, loaded);
+ expect(again).toMatchObject({ change: { region: { before: 'visited' }, regions: { before: 2, after: 2 } } });
+ expect(ATLAS_CHANGES.mark_region_visited(ctx, { regionCode: 'Osaka', countryCode: 'JP' }, loaded)).toBeNull();
+ });
+
+ it('ASTATLAS-010: a region unmark keeps the country while others are left; the last one leaves it to the trips', () => {
+ expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'JP-26' }, loaded)).toMatchObject({
+ code: 'JP',
+ change: {
+ op: 'unmark',
+ region: { code: 'JP-26', name: 'Kyoto', before: 'visited', after: 'none' },
+ after: 'visited',
+ countries: { before: 3, after: 3 },
+ regions: { before: 2, after: 1 },
+ },
+ });
+ const last = valid(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'DE-BY' }, loaded));
+ expect(last).toMatchObject({
+ change: { after: 'unknown', countries: { before: 3, after: null }, regions: { before: 1, after: 0 } },
+ });
+ // A region the snapshot does not know is named by its code, its country by the code's prefix.
+ expect(ATLAS_CHANGES.unmark_region_visited(ctx, { regionCode: 'FR-IDF' }, loaded)).toMatchObject({
+ code: 'FR',
+ change: { region: { name: 'FR-IDF', before: null } },
+ });
+ });
+
+ it('ASTATLAS-011: an add shows the wish from its arguments and knows a duplicate, which does not grow the list', () => {
+ const patagonia = { name: 'Patagonia', country_code: 'ar', target_date: '2028', notes: 'Torres del Paine' };
+ const add = valid(ATLAS_CHANGES.create_bucket_list_item(ctx, { ...patagonia, lat: -50.9, lng: -73.4 }, loaded));
+ expect(add).toMatchObject({
+ items: [{ id: null, name: 'Patagonia', countryCode: 'AR', targetDate: '2028', notes: 'Torres del Paine' }],
+ change: { op: 'add', duplicate: false, wishes: { before: 4, after: 5 } },
+ });
+ expect(JSON.stringify(add)).not.toContain('-50.9');
+ const dup = ATLAS_CHANGES.create_bucket_list_item(
+ ctx,
+ { name: ' northern LIGHTS ', country_code: 'is', target_date: '2027-02', lat: 64.1, lng: -21.9 },
+ loaded,
+ );
+ expect(dup).toMatchObject({ change: { duplicate: true, wishes: { before: 4, after: 4 } } });
+ const otherDate = ATLAS_CHANGES.create_bucket_list_item(
+ ctx,
+ { name: 'Northern lights', country_code: 'IS', target_date: '2028-02', lat: 64.1, lng: -21.9 },
+ loaded,
+ );
+ expect(otherDate).toMatchObject({ change: { duplicate: false } });
+ expect(ATLAS_CHANGES.create_bucket_list_item(ctx, {}, loaded)).toBeNull();
+ });
+
+ it('ASTATLAS-012: a change shows the wish as it will read, a removal as it reads, and an unknown id draws no card', () => {
+ const update = valid(
+ ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 5, name: 'Aurora', target_date: '2027-03' }, loaded),
+ );
+ expect(update).toMatchObject({
+ items: [{ id: 5, name: 'Aurora', targetDate: '2027-03', notes: 'Glacier hike', countryCode: 'IS' }],
+ change: { op: 'update', before: { name: 'Northern lights', targetDate: '2027-02' }, duplicate: false },
+ });
+ const clash = ATLAS_CHANGES.update_bucket_list_item(
+ ctx,
+ { itemId: 6, name: 'Northern lights', country_code: 'IS', target_date: '2027-02', lat: 64.1, lng: -21.9 },
+ loaded,
+ );
+ expect(clash).toMatchObject({ change: { duplicate: true } });
+ expect(ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 99, name: 'x' }, loaded)).toBeNull();
+ const remove = valid(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, loaded));
+ expect(remove).toMatchObject({
+ items: [{ id: 6, name: 'Kyoto in autumn' }],
+ change: { op: 'remove', before: { id: 6 }, wishes: { before: 4, after: 3 } },
+ });
+ expect(ATLAS_CHANGES.delete_bucket_list_item(ctx, { itemId: 6 }, null)).toBeNull();
+ });
+
+ it('ASTATLAS-013: the held card names the country in the user language, under the tool label in the atlas', () => {
+ expect(countryNameIn({ locale: 'de' }, 'FR')).toBe('Frankreich');
+ expect(countryNameIn({ locale: 'not a locale!' }, 'FR')).toBe('France');
+ const german = { ...ctx, locale: 'de' };
+ const preview = describeToolCall('mark_country_visited', 'tc-1', { country_code: 'fr' }, german, loaded);
+ expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
+ expect(preview).toMatchObject({
+ verbKey: 'genericCreate',
+ subject: 'Frankreich',
+ tool: 'mark_country_visited',
+ domain: 'atlas',
+ undoable: false,
+ card: { kind: 'atlasCountry', code: 'FR' },
+ });
+ const region = describeToolCall('unmark_region_visited', 'tc-2', { regionCode: 'JP-26' }, ctx, loaded);
+ expect(region).toMatchObject({ verbKey: 'genericRemove', subject: 'Kyoto' });
+ expect(region.rows).toEqual([{ op: 'add', labelKey: 'location', before: null, after: 'Japan' }]);
+ const wish = describeToolCall('update_bucket_list_item', 'tc-3', { itemId: 5, notes: 'With Ana' }, ctx, loaded);
+ expect(wish).toMatchObject({
+ subject: 'Northern lights',
+ rows: [{ op: 'change', labelKey: 'notes', before: 'Glacier hike', after: 'With Ana' }],
+ });
+ const add = describeToolCall('create_bucket_list_item', 'tc-4', { name: 'Petra' }, ctx, loaded);
+ expect(add).toMatchObject({ undoable: true, subject: 'Petra' });
+ expect(describeToolCall('delete_bucket_list_item', 'tc-5', { itemId: 8 }, ctx, loaded)).toMatchObject({
+ verbKey: 'genericRemove',
+ subject: 'Petra',
+ });
+ });
+});
+
+describe('atlas lookups, applied cards and undo', () => {
+ it('ASTATLAS-014: each change reads only what it needs, and a failed read leaves its snapshot empty', async () => {
+ const answers: Record = { get_atlas_stats: STATS, list_visited_regions: REGIONS };
+ const read = vi.fn((tool) => Promise.resolve(answers[tool] ?? null));
+ expect((await loadAtlas({}, read)).atlas?.visited).toBe(3);
+ expect(read).toHaveBeenCalledWith('get_atlas_stats');
+ const both = await loadAtlasRegions({}, read);
+ expect(both.regions?.get('JP-26')).toEqual({ name: 'Kyoto', country: 'JP', status: 'visited' });
+ expect(both.bucket).toBeNull();
+ expect((await loadBucket({}, read)).bucket).toBeNull();
+ expect(read).toHaveBeenCalledTimes(4);
+ expect(atlasSnapshotOf({ stats: {} })).toBeNull();
+ expect(regionsSnapshotOf('nope')).toBeNull();
+ });
+
+ it('ASTATLAS-015: the applied country takes the standing and the counters the write answered', () => {
+ const held = describeToolCall('unmark_country_visited', 'tc-6', { country_code: 'JP' }, ctx, loaded);
+ const applied = appliedCardOf('unmark_country_visited', {
+ args: { country_code: 'JP' },
+ result: { success: true, country_code: 'JP', after: { country_status: 'visited', countries_visited: 3 } },
+ preview: held,
+ });
+ expect(valid(applied)).toMatchObject({ change: { after: 'visited', countries: { before: 3, after: 3 } } });
+ const region = describeToolCall('unmark_region_visited', 'tc-7', { regionCode: 'DE-BY' }, ctx, loaded);
+ const gone = appliedCardOf('unmark_region_visited', {
+ args: { regionCode: 'DE-BY' },
+ result: { success: true, after: { country_status: null, countries_visited: 2, regions_visited: 0 } },
+ preview: region,
+ });
+ expect(gone).toMatchObject({
+ change: { after: 'none', countries: { after: 2 }, region: { after: 'none' }, regions: { before: 1, after: 0 } },
+ });
+ // Without a held card there is nothing to build on.
+ const blind = { args: { country_code: 'JP' }, result: {}, preview: null };
+ expect(appliedCardOf('mark_country_visited', blind)).toBeNull();
+ });
+
+ it('ASTATLAS-016: an applied wish carries its stored id, a removal keeps its held card', () => {
+ const held = describeToolCall('create_bucket_list_item', 'tc-8', { name: 'Patagonia' }, ctx, loaded);
+ const added = appliedCardOf('create_bucket_list_item', {
+ args: { name: 'Patagonia' },
+ result: { item: { ...WISH, id: 41, name: 'Patagonia', country_code: 'AR', notes: 'mail me at ana@example.com' } },
+ preview: held,
+ });
+ expect(valid(added)).toMatchObject({
+ items: [{ id: 41, name: 'Patagonia', countryCode: 'AR' }],
+ change: { op: 'add', wishes: { before: 4, after: 5 } },
+ });
+ expect(JSON.stringify(added)).not.toContain('ana@example.com');
+ const removal = describeToolCall('delete_bucket_list_item', 'tc-9', { itemId: 5 }, ctx, loaded);
+ const input = { args: { itemId: 5 }, result: { success: true }, preview: removal };
+ const removed = appliedCardOf('delete_bucket_list_item', input);
+ expect(removed).toEqual(removal.card);
+ });
+
+ it('ASTATLAS-017: an added wish is taken back while it reads as added, never once edited or reached', () => {
+ const result = { item: { ...WISH, id: 41 } };
+ expect(ASSISTANT_UNDO_MAP.create_bucket_list_item?.tool).toBe('delete_bucket_list_item');
+ expect(DELETE_ADDED_WISH.args({}, result, null)).toEqual({ itemId: 41 });
+ expect(DELETE_ADDED_WISH.args({}, { success: false }, null)).toBeNull();
+ const reads = DELETE_ADDED_WISH.reads?.({}, result) ?? [];
+ expect(reads).toEqual([{ tool: 'list_bucket_list', args: {} }]);
+ const snapshot = (items: unknown[]) => ({
+ summary: null,
+ places: null,
+ reads: new Map([[readKey('list_bucket_list', {}), { items }]]),
+ });
+ expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41 }]))).toBe(true);
+ expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, notes: 'Edited' }]))).toBe(false);
+ expect(DELETE_ADDED_WISH.unchanged(result, snapshot([{ ...WISH, id: 41, visited_at: '2026-10-01' }]))).toBe(false);
+ expect(DELETE_ADDED_WISH.unchanged(result, snapshot([]))).toBe(false);
+ });
+
+ it('ASTATLAS-018: the registry holds the atlas tools, guards their cards and adds the two prompt lines', () => {
+ const marks = ['mark_country_visited', 'unmark_country_visited', 'mark_region_visited', 'unmark_region_visited'];
+ for (const tool of marks) {
+ expect(ASSISTANT_WRITE_TOOLS[tool]?.entity).toBe('atlas');
+ expect(TOOL_CARDS[tool]?.load).toBeTypeOf('function');
+ }
+ expect(ASSISTANT_WRITE_TOOLS.update_bucket_list_item).toEqual({ entity: 'atlas', verbKey: 'genericUpdate' });
+ const input = { toolCallId: 'tc-10', card, today: '2026-10-22' };
+ const passport = answerCardOf('get_atlas_stats', { ...input, args: {}, value: STATS });
+ expect(valid(passport)).toMatchObject({ kind: 'atlasPassport' });
+ const bucket = answerCardOf('list_bucket_list', { ...input, args: {}, value: BUCKET });
+ expect(bucket).toMatchObject({ kind: 'atlasBucket' });
+ expect(answerCardOf('get_country_atlas_places', { ...input, args: { countryCode: 'x' }, value: {} })).toBeNull();
+ expect(CARD_PROMPT_LINES.some((line) => line.includes('list_bucket_list'))).toBe(true);
+ });
+
+ it('ASTATLAS-019: the duplicate check compares a country as stored and a name only trimmed, as the service does', () => {
+ const stored = bucketSnapshotOf({
+ items: [
+ { ...WISH, id: 20, name: 'Kyoto', country_code: 'jp', lat: null, lng: null, target_date: null },
+ { ...WISH, id: 21, name: 'Osaka Castle', country_code: 'JP', lat: null, lng: null, target_date: null },
+ ],
+ });
+ const lower: AtlasLoaded = { atlas: null, regions: null, bucket: stored };
+ // An update keeps its country as given: 'jp' meets the stored 'jp', 'JP' does not.
+ const same = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'jp' }, lower);
+ expect(same).toMatchObject({ change: { duplicate: true } });
+ const upper = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 21, name: 'Kyoto', country_code: 'JP' }, lower);
+ expect(upper).toMatchObject({ change: { duplicate: false } });
+ // An add is stored upper-cased by its schema, so 'jp' lands as 'JP' and misses the stored 'jp'.
+ const add = ATLAS_CHANGES.create_bucket_list_item(ctx, { name: 'Kyoto', country_code: 'jp' }, lower);
+ expect(add).toMatchObject({ change: { duplicate: false } });
+ // Inner spaces stay, as the service trims the ends only: the double space meets the stored one.
+ const spaced = { itemId: 20, name: ' osaka castle ', country_code: 'JP' };
+ expect(ATLAS_CHANGES.update_bucket_list_item(ctx, spaced, lower)).toMatchObject({ change: { duplicate: true } });
+ // A blank name keeps the stored one, so the wish clashes as it is.
+ const blank = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 20, name: ' ', country_code: 'JP' }, lower);
+ expect(blank).toMatchObject({ items: [{ name: 'Kyoto' }], change: { duplicate: false } });
+ });
+
+ it('ASTATLAS-020: a wish moved to another country loses its region, one kept in its country keeps it', () => {
+ const regional = bucketSnapshotOf({ items: [{ ...WISH, id: 30, region_code: 'IS-1' }] });
+ const withRegion: AtlasLoaded = { atlas: null, regions: null, bucket: regional };
+ const moved = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, country_code: 'NO' }, withRegion);
+ expect(moved).toMatchObject({
+ items: [{ countryCode: 'NO', regionCode: null }],
+ change: { before: { regionCode: 'IS-1' } },
+ });
+ const kept = ATLAS_CHANGES.update_bucket_list_item(ctx, { itemId: 30, notes: 'In March' }, withRegion);
+ expect(kept).toMatchObject({ items: [{ countryCode: 'IS', regionCode: 'IS-1', notes: 'In March' }] });
+ });
+
+ it('ASTATLAS-021: a wish change is named by its new name, else its stored one, else its id', () => {
+ const subject = (tool: string, args: Record) =>
+ describeToolCall(tool, 'tc-21', args, ctx, loaded).subject;
+ expect(subject('update_bucket_list_item', { itemId: 5, name: 'Aurora' })).toBe('Aurora');
+ expect(subject('update_bucket_list_item', { itemId: 5, target_date: '2027-03' })).toBe('Northern lights');
+ expect(subject('update_bucket_list_item', { itemId: 99, notes: 'x' })).toBe('#99');
+ // A removal never takes a name from its arguments.
+ expect(subject('delete_bucket_list_item', { itemId: 6, name: 'Ignored' })).toBe('Kyoto in autumn');
+ expect(subject('delete_bucket_list_item', { itemId: 99 })).toBe('#99');
+ expect(subject('delete_bucket_list_item', {})).toBe('');
+ });
+});
diff --git a/shared/src/assistant/cards/atlas-bucket.card.ts b/shared/src/assistant/cards/atlas-bucket.card.ts
index f95487a3b..b9d3a2c8c 100644
--- a/shared/src/assistant/cards/atlas-bucket.card.ts
+++ b/shared/src/assistant/cards/atlas-bucket.card.ts
@@ -1,5 +1,49 @@
+import { idSchema } from '../../common/primitives.schema';
+import { atlasCountryCodeSchema, atlasRegionCodeSchema, atlasTallySchema } from './atlas-country.card';
+import { cardCountSchema, cardDateSchema, cardText } from './card-parts';
+
import { z } from 'zod';
-/** Entries of the bucket list. Only its kind so far; the atlas cards give it its fields. */
-export const atlasBucketCardSchema = z.object({ kind: z.literal('atlasBucket') });
+/** When a wish is planned for: a year, a month or a day ("2027", "2027-05", "2027-05-14"). */
+export const atlasTargetDateSchema = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/);
+
+/** One wish of the bucket list. Never its coordinates: the card names it, the Atlas places it. */
+export const atlasBucketEntrySchema = z.object({
+ /** Null on a wish that is still to be added. */
+ id: idSchema.nullable(),
+ name: cardText(120),
+ /** Null without a country, and for a legacy alpha-3 code (the card shows a star then). */
+ countryCode: atlasCountryCodeSchema.nullable(),
+ regionCode: atlasRegionCodeSchema.nullable(),
+ targetDate: atlasTargetDateSchema.nullable(),
+ notes: cardText(200).nullable(),
+ /** The day the wish came true. */
+ reachedOn: cardDateSchema.nullable(),
+ reachedFrom: z.enum(['manual', 'dawarich']).nullable(),
+});
+
+/** What an add, a change or a removal does to the bucket list. */
+export const atlasBucketChangeSchema = z.object({
+ op: z.enum(['add', 'update', 'remove']),
+ /** The wish as it is: set on a change and a removal. */
+ before: atlasBucketEntrySchema.nullable(),
+ /** The same wish is on the list already, so the add will be refused. */
+ duplicate: z.boolean(),
+ /** How many wishes the list holds. */
+ wishes: atlasTallySchema.nullable(),
+});
+
+/**
+ * Wishes of the bucket list: the open ones first, soonest first, then the ones
+ * that came true. An answer shows the list; a change card shows the one wish
+ * it adds, changes or removes (`items[0]`).
+ */
+export const atlasBucketCardSchema = z.object({
+ kind: z.literal('atlasBucket'),
+ items: z.array(atlasBucketEntrySchema).min(1).max(12),
+ more: cardCountSchema,
+ change: atlasBucketChangeSchema.nullable(),
+});
export type AtlasBucketCard = z.infer;
+export type AtlasBucketEntry = z.infer;
+export type AtlasBucketChange = z.infer;
diff --git a/shared/src/assistant/cards/atlas-cards.spec.ts b/shared/src/assistant/cards/atlas-cards.spec.ts
new file mode 100644
index 000000000..63cc1b254
--- /dev/null
+++ b/shared/src/assistant/cards/atlas-cards.spec.ts
@@ -0,0 +1,107 @@
+import { atlasBucketCardSchema, type AtlasBucketCard, type AtlasBucketEntry } from './atlas-bucket.card';
+import { atlasCountryCardSchema, type AtlasCountryCard } from './atlas-country.card';
+import { atlasPassportCardSchema, type AtlasPassportCard } from './atlas-passport.card';
+import { ATLAS_APPLIED_ENTITIES, ATLAS_CARD_SCHEMAS } from './atlas.contract';
+import { storedTrekCardSchema, trekCardSchema } from './index';
+
+import { describe, expect, it } from 'vitest';
+
+const passport: AtlasPassportCard = {
+ kind: 'atlasPassport',
+ countries: { visited: 23, planned: 4, idea: 1 },
+ totals: { trips: 48, days: 410, places: 990, cities: 312, flownKm: 182_300 },
+ flags: [
+ { code: 'JP', status: 'visited' },
+ { code: 'PT', status: 'planned' },
+ ],
+ moreFlags: 21,
+ continents: [{ name: 'Europe', visited: 14, planned: 2 }],
+ nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
+ streakYears: 3,
+};
+
+const kyoto = { code: 'JP-26', name: 'Kyoto', before: null, after: 'visited' as const };
+const markJapan: NonNullable = {
+ op: 'mark',
+ region: kyoto,
+ after: 'visited',
+ countries: { before: 23, after: 24 },
+ regions: { before: 0, after: 1 },
+};
+const kyotoTrip = { id: 7, title: 'Kyoto in spring', startDate: '2024-04-03', endDate: '2024-04-14' };
+
+const country: AtlasCountryCard = {
+ kind: 'atlasCountry',
+ code: 'JP',
+ status: 'planned',
+ markedBy: null,
+ placeCount: 14,
+ tripCount: 2,
+ trips: [kyotoTrip],
+ moreTrips: 1,
+ places: ['Fushimi Inari'],
+ morePlaces: 13,
+ change: markJapan,
+};
+
+const lights: AtlasBucketEntry = {
+ id: null,
+ name: 'Northern lights',
+ countryCode: 'IS',
+ regionCode: null,
+ targetDate: '2027-02',
+ notes: 'Glacier hike and hot springs',
+ reachedOn: null,
+ reachedFrom: null,
+};
+
+const bucket: AtlasBucketCard = {
+ kind: 'atlasBucket',
+ items: [lights],
+ more: 0,
+ change: { op: 'add', before: null, duplicate: false, wishes: { before: 7, after: 8 } },
+};
+
+describe('the atlas cards', () => {
+ it('CARDS-ATLAS-001: the passport, a country change and a bucket wish parse as themselves and in the union', () => {
+ expect(atlasPassportCardSchema.parse(passport)).toEqual(passport);
+ expect(atlasCountryCardSchema.parse(country)).toEqual(country);
+ expect(atlasBucketCardSchema.parse(bucket)).toEqual(bucket);
+ for (const card of [passport, country, bucket]) {
+ expect(trekCardSchema.parse(card)).toEqual(card);
+ expect(JSON.stringify(card)).not.toMatch(/https?:/);
+ }
+ expect(ATLAS_CARD_SCHEMAS).toHaveLength(3);
+ expect(ATLAS_APPLIED_ENTITIES).toEqual(['atlas']);
+ });
+
+ it('CARDS-ATLAS-002: a lower case or alpha-3 country code and a region outside the ISO form are refused', () => {
+ expect(atlasCountryCardSchema.safeParse({ ...country, code: 'jp' }).success).toBe(false);
+ expect(atlasCountryCardSchema.safeParse({ ...country, code: 'JPN' }).success).toBe(false);
+ const region = { ...kyoto, code: 'Kyoto' };
+ expect(atlasCountryCardSchema.safeParse({ ...country, change: { ...markJapan, region } }).success).toBe(false);
+ const wish = { ...lights, countryCode: 'isl' };
+ expect(atlasBucketCardSchema.safeParse({ ...bucket, items: [wish] }).success).toBe(false);
+ });
+
+ it('CARDS-ATLAS-003: lists past their bounds and an empty bucket card do not parse, and read as null', () => {
+ const flags = Array.from({ length: 41 }, () => ({ code: 'FR', status: 'visited' as const }));
+ expect(atlasPassportCardSchema.safeParse({ ...passport, flags }).success).toBe(false);
+ const trips = Array.from({ length: 5 }, () => kyotoTrip);
+ expect(atlasCountryCardSchema.safeParse({ ...country, trips }).success).toBe(false);
+ expect(atlasBucketCardSchema.safeParse({ ...bucket, items: [] }).success).toBe(false);
+ expect(storedTrekCardSchema.parse({ ...bucket, items: [] })).toBeNull();
+ expect(storedTrekCardSchema.parse({ ...passport, countries: { visited: -1, planned: 0, idea: 0 } })).toBeNull();
+ });
+
+ it('CARDS-ATLAS-004: a target date is a year, a month or a day, and a counter after a change may be unknown', () => {
+ for (const targetDate of ['2027', '2027-05', '2027-05-14']) {
+ const wish = { ...lights, targetDate };
+ expect(atlasBucketCardSchema.safeParse({ ...bucket, items: [wish] }).success).toBe(true);
+ }
+ const wish = { ...lights, targetDate: 'May 2027' };
+ expect(atlasBucketCardSchema.safeParse({ ...bucket, items: [wish] }).success).toBe(false);
+ const unknown = { ...markJapan, after: 'unknown' as const, countries: { before: 23, after: null } };
+ expect(atlasCountryCardSchema.parse({ ...country, change: unknown }).change?.countries?.after).toBeNull();
+ });
+});
diff --git a/shared/src/assistant/cards/atlas-country.card.ts b/shared/src/assistant/cards/atlas-country.card.ts
index 048bd97a7..c1e03a3ca 100644
--- a/shared/src/assistant/cards/atlas-country.card.ts
+++ b/shared/src/assistant/cards/atlas-country.card.ts
@@ -1,5 +1,70 @@
+import { visitStatusSchema } from '../../atlas/atlas.schema';
+import { idSchema } from '../../common/primitives.schema';
+import { cardCountSchema, cardDateSchema, cardText } from './card-parts';
+
import { z } from 'zod';
-/** One country of the atlas. Only its kind so far; the atlas cards give it its fields. */
-export const atlasCountryCardSchema = z.object({ kind: z.literal('atlasCountry') });
+/** ISO 3166-1 alpha-2 in upper case: the only code a flag and a country name are drawn from. */
+export const atlasCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/);
+
+/** ISO 3166-2 as the atlas stores a region ("DE-BY", "US-CA"). */
+export const atlasRegionCodeSchema = z.string().regex(/^[A-Z]{2}-[A-Z0-9]{1,8}$/);
+
+/** A counter a change moves. `after` is null when the user's trips decide it, known once the change is applied. */
+export const atlasTallySchema = z.object({ before: cardCountSchema, after: cardCountSchema.nullable() });
+
+/** Where a country or a region stands after a change: `none` is off the map, `unknown` is up to the trips. */
+export const atlasStatusAfterSchema = z.enum(['visited', 'planned', 'idea', 'none', 'unknown']);
+
+/** A trip that went to the country, as a line of the card. */
+const atlasTripLineSchema = z.object({
+ id: idSchema,
+ title: cardText(80),
+ startDate: cardDateSchema.nullable(),
+ endDate: cardDateSchema.nullable(),
+});
+
+/** What a mark or an unmark does to the country, or to one of its regions. */
+export const atlasCountryChangeSchema = z.object({
+ op: z.enum(['mark', 'unmark']),
+ /** Set when the change is about one region of the country. */
+ region: z
+ .object({
+ code: atlasRegionCodeSchema,
+ name: cardText(80),
+ before: visitStatusSchema.nullable(),
+ after: atlasStatusAfterSchema,
+ })
+ .nullable(),
+ /** The country's own status once the change is applied. */
+ after: atlasStatusAfterSchema,
+ /** Visited countries of the whole atlas; null when the atlas could not be read. */
+ countries: atlasTallySchema.nullable(),
+ /** Visited regions of this country, on region changes only. */
+ regions: atlasTallySchema.nullable(),
+});
+
+/**
+ * One country of the atlas: its status, who marked it, the trips and places
+ * that put it there. An answer (`change` null) shows it as it is now; a change
+ * card shows it as it was, with what the mark or unmark does to it and to the
+ * visited counters.
+ */
+export const atlasCountryCardSchema = z.object({
+ kind: z.literal('atlasCountry'),
+ code: atlasCountryCodeSchema,
+ /** Now on an answer, before the change on a change card; null when the country is not on the map. */
+ status: visitStatusSchema.nullable(),
+ markedBy: z.enum(['manual', 'dawarich', 'region']).nullable(),
+ placeCount: cardCountSchema.nullable(),
+ tripCount: cardCountSchema.nullable(),
+ trips: z.array(atlasTripLineSchema).max(4),
+ moreTrips: cardCountSchema,
+ places: z.array(cardText(80)).max(8),
+ morePlaces: cardCountSchema,
+ change: atlasCountryChangeSchema.nullable(),
+});
export type AtlasCountryCard = z.infer;
+export type AtlasCountryChange = z.infer;
+export type AtlasTally = z.infer;
+export type AtlasStatusAfter = z.infer;
diff --git a/shared/src/assistant/cards/atlas-passport.card.ts b/shared/src/assistant/cards/atlas-passport.card.ts
index 9ad77750a..1a1465f8e 100644
--- a/shared/src/assistant/cards/atlas-passport.card.ts
+++ b/shared/src/assistant/cards/atlas-passport.card.ts
@@ -1,8 +1,52 @@
+import { visitStatusSchema } from '../../atlas/atlas.schema';
+import { atlasCountryCodeSchema } from './atlas-country.card';
+import { cardCountSchema, cardDateSchema, cardText } from './card-parts';
+
import { z } from 'zod';
+/** The continents of CONTINENT_MAP and its fallback; the client names them with the Atlas page's own words. */
+export const ATLAS_CARD_CONTINENTS = [
+ 'Europe',
+ 'Asia',
+ 'North America',
+ 'South America',
+ 'Africa',
+ 'Oceania',
+ 'Antarctica',
+ 'Other',
+] as const;
+export const atlasCardContinentSchema = z.enum(ATLAS_CARD_CONTINENTS);
+export type AtlasCardContinent = z.infer;
+
/**
- * The countries and regions a user has been to. Only its kind so far; the atlas cards give it its
- * fields.
+ * The user's travel picture as the Atlas page shows it: visited, planned and
+ * wished-for countries, the trips, days, places and cities behind them, the
+ * distance flown, the flags, the continents, the next trip and the streak.
*/
-export const atlasPassportCardSchema = z.object({ kind: z.literal('atlasPassport') });
+export const atlasPassportCardSchema = z.object({
+ kind: z.literal('atlasPassport'),
+ countries: z.object({ visited: cardCountSchema, planned: cardCountSchema, idea: cardCountSchema }),
+ totals: z.object({
+ trips: cardCountSchema,
+ days: cardCountSchema,
+ places: cardCountSchema,
+ cities: cardCountSchema,
+ /** Rounded kilometres; null when nothing was flown. */
+ flownKm: cardCountSchema.nullable(),
+ }),
+ /** Visited countries, latest first, then planned ones, soonest first. */
+ flags: z.array(z.object({ code: atlasCountryCodeSchema, status: visitStatusSchema })).max(40),
+ moreFlags: cardCountSchema,
+ continents: z
+ .array(z.object({ name: atlasCardContinentSchema, visited: cardCountSchema, planned: cardCountSchema }))
+ .max(8),
+ nextTrip: z
+ .object({
+ title: cardText(80),
+ startDate: cardDateSchema.nullable(),
+ daysUntil: z.number().int().min(0).max(36_500).nullable(),
+ })
+ .nullable(),
+ streakYears: z.number().int().min(0).max(200).nullable(),
+});
export type AtlasPassportCard = z.infer;
diff --git a/shared/src/assistant/cards/atlas.contract.ts b/shared/src/assistant/cards/atlas.contract.ts
index 3ac0678ba..58bce8eb6 100644
--- a/shared/src/assistant/cards/atlas.contract.ts
+++ b/shared/src/assistant/cards/atlas.contract.ts
@@ -15,4 +15,5 @@ export * from './atlas-passport.card';
*/
export const ATLAS_CARD_SCHEMAS = [atlasPassportCardSchema, atlasCountryCardSchema, atlasBucketCardSchema] as const;
export const ATLAS_VERB_KEYS = [] as const;
-export const ATLAS_APPLIED_ENTITIES = [] as const;
+/** A country, a region or a wish of the bucket list: the receipt opens the Atlas. */
+export const ATLAS_APPLIED_ENTITIES = ['atlas'] as const;
diff --git a/shared/src/i18n/ar/assistantAtlas.ts b/shared/src/i18n/ar/assistantAtlas.ts
index 3d8e7daa8..0fb605801 100644
--- a/shared/src/i18n/ar/assistantAtlas.ts
+++ b/shared/src/i18n/ar/assistantAtlas.ts
@@ -1,4 +1,68 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'أطلسك',
+ 'assistant.card.atlas.planned': '{count} مُخطط',
+ 'assistant.card.atlas.planned.zero': 'لا شيء مُخطط',
+ 'assistant.card.atlas.planned.one': '{count} مُخطط',
+ 'assistant.card.atlas.planned.two': '{count} مُخطط لهما',
+ 'assistant.card.atlas.planned.few': '{count} مُخططة',
+ 'assistant.card.atlas.planned.many': '{count} مُخططًا',
+ 'assistant.card.atlas.factFlown': 'الطيران',
+ 'assistant.card.atlas.open': 'فتح الأطلس',
+ 'assistant.card.atlas.nextTrip': 'الرحلة القادمة: {title} بعد {count} يوم',
+ 'assistant.card.atlas.nextTrip.zero': 'الرحلة القادمة: {title} بعد {count} يوم',
+ 'assistant.card.atlas.nextTrip.one': 'الرحلة القادمة: {title} بعد يوم واحد',
+ 'assistant.card.atlas.nextTrip.two': 'الرحلة القادمة: {title} بعد يومين',
+ 'assistant.card.atlas.nextTrip.few': 'الرحلة القادمة: {title} بعد {count} أيام',
+ 'assistant.card.atlas.nextTrip.many': 'الرحلة القادمة: {title} بعد {count} يومًا',
+ 'assistant.card.atlas.nextTripToday': 'الرحلة القادمة: {title} تبدأ اليوم',
+ 'assistant.card.atlas.status.visited': 'تمت زيارتها',
+ 'assistant.card.atlas.status.planned': 'مُخطط لها',
+ 'assistant.card.atlas.status.idea': 'فكرة',
+ 'assistant.card.atlas.status.none': 'ليست على خريطتك',
+ 'assistant.card.atlas.status.unknown': 'رحلاتك تقرر',
+ 'assistant.card.atlas.dates': 'من {from} إلى {to}',
+ 'assistant.card.atlas.fromRecordings': 'من تسجيلاتك',
+ 'assistant.card.atlas.tallyCountries': 'الدول التي تمت زيارتها',
+ 'assistant.card.atlas.tallyRegions': 'المناطق في {country}',
+ 'assistant.card.atlas.tallyWishes': 'قائمة الأمنيات',
+ 'assistant.card.atlas.reached': 'تحققت في {date}',
+ 'assistant.card.atlas.duplicate': 'موجودة في قائمتك بالفعل',
+ 'assistant.card.atlas.trips': '{count} رحلة',
+ 'assistant.card.atlas.trips.zero': 'لا رحلات',
+ 'assistant.card.atlas.trips.one': 'رحلة واحدة',
+ 'assistant.card.atlas.trips.two': 'رحلتان',
+ 'assistant.card.atlas.trips.few': '{count} رحلات',
+ 'assistant.card.atlas.trips.many': '{count} رحلة',
+ 'assistant.card.atlas.places': '{count} مكان',
+ 'assistant.card.atlas.places.zero': 'لا أماكن',
+ 'assistant.card.atlas.places.one': 'مكان واحد',
+ 'assistant.card.atlas.places.two': 'مكانان',
+ 'assistant.card.atlas.places.few': '{count} أماكن',
+ 'assistant.card.atlas.places.many': '{count} مكانًا',
+ 'assistant.card.atlas.streak': '{count} سنة متتالية',
+ 'assistant.card.atlas.streak.zero': 'لا سنوات متتالية',
+ 'assistant.card.atlas.streak.one': 'سنة واحدة متتالية',
+ 'assistant.card.atlas.streak.two': 'سنتان متتاليتان',
+ 'assistant.card.atlas.streak.few': '{count} سنوات متتالية',
+ 'assistant.card.atlas.streak.many': '{count} سنة متتالية',
+ 'assistant.tool.unmark_country_visited': 'إزالة دولة تمت زيارتها',
+ 'assistant.tool.mark_region_visited': 'تحديد المنطقة كمُزارة',
+ 'assistant.tool.unmark_region_visited': 'إزالة منطقة تمت زيارتها',
+ 'assistant.tool.create_bucket_list_item': 'إضافة إلى قائمة الأمنيات',
+ 'assistant.tool.update_bucket_list_item': 'تعديل أمنية في القائمة',
+ 'assistant.tool.delete_bucket_list_item': 'إزالة من قائمة الأمنيات',
+ 'assistant.step.atlas': 'قراءة أطلسك',
+ 'assistant.step.atlasCountry': 'إلقاء نظرة على دولة',
+ 'assistant.step.regions': 'قراءة مناطقك',
+ 'assistant.step.locateRegion': 'البحث عن المنطقة',
+ 'assistant.step.bucketList': 'قراءة قائمة أمنياتك',
+ 'assistant.followUp.atlasWishes': 'ما الذي في قائمة أمنياتي؟',
+ 'assistant.followUp.atlasLeast': 'أي قارة رأيت منها أقل؟',
+ 'assistant.followUp.atlasMark': 'حدّدها كمُزارة',
+ 'assistant.followUp.atlasWish': 'أضفها إلى قائمة أمنياتي',
+ 'assistant.followUp.atlasPlanWish': 'أي أمنية يجب أن أخطط لها بعد ذلك؟',
+ 'assistant.followUp.atlasAddWish': 'أضف أمنية أخرى',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/az/assistantAtlas.ts b/shared/src/i18n/az/assistantAtlas.ts
index 3d8e7daa8..c03716d9e 100644
--- a/shared/src/i18n/az/assistantAtlas.ts
+++ b/shared/src/i18n/az/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Atlasınız',
+ 'assistant.card.atlas.planned': '{count} planlaşdırılıb',
+ 'assistant.card.atlas.planned.one': '{count} planlaşdırılıb',
+ 'assistant.card.atlas.factFlown': 'Uçuş',
+ 'assistant.card.atlas.open': 'Atlası aç',
+ 'assistant.card.atlas.nextTrip': 'Növbəti səyahət: {title}, {count} gün sonra',
+ 'assistant.card.atlas.nextTrip.one': 'Növbəti səyahət: {title}, {count} gün sonra',
+ 'assistant.card.atlas.nextTripToday': 'Növbəti səyahət: {title} bu gün başlayır',
+ 'assistant.card.atlas.status.visited': 'Ziyarət edilib',
+ 'assistant.card.atlas.status.planned': 'Planlaşdırılıb',
+ 'assistant.card.atlas.status.idea': 'İdeya',
+ 'assistant.card.atlas.status.none': 'Xəritənizdə yoxdur',
+ 'assistant.card.atlas.status.unknown': 'Səyahətləriniz həll edir',
+ 'assistant.card.atlas.dates': '{from} ilə {to} arası',
+ 'assistant.card.atlas.fromRecordings': 'Qeydlərinizdən',
+ 'assistant.card.atlas.tallyCountries': 'Ziyarət edilmiş ölkələr',
+ 'assistant.card.atlas.tallyRegions': '{country} bölgələri',
+ 'assistant.card.atlas.tallyWishes': 'Arzular siyahısı',
+ 'assistant.card.atlas.reached': '{date} tarixində çatılıb',
+ 'assistant.card.atlas.duplicate': 'Artıq siyahınızdadır',
+ 'assistant.card.atlas.trips': '{count} səyahət',
+ 'assistant.card.atlas.trips.one': '{count} səyahət',
+ 'assistant.card.atlas.places': '{count} məkan',
+ 'assistant.card.atlas.places.one': '{count} məkan',
+ 'assistant.card.atlas.streak': '{count} ardıcıl il',
+ 'assistant.card.atlas.streak.one': '{count} ardıcıl il',
+ 'assistant.tool.unmark_country_visited': 'Ziyarət edilmiş ölkəni sil',
+ 'assistant.tool.mark_region_visited': 'Bölgəni ziyarət edilmiş kimi işarələ',
+ 'assistant.tool.unmark_region_visited': 'Ziyarət edilmiş bölgəni sil',
+ 'assistant.tool.create_bucket_list_item': 'Arzular siyahısına əlavə et',
+ 'assistant.tool.update_bucket_list_item': 'Arzular siyahısındakı arzunu dəyiş',
+ 'assistant.tool.delete_bucket_list_item': 'Arzular siyahısından sil',
+ 'assistant.step.atlas': 'Atlasınız oxunur',
+ 'assistant.step.atlasCountry': 'Ölkəyə baxılır',
+ 'assistant.step.regions': 'Bölgələriniz oxunur',
+ 'assistant.step.locateRegion': 'Bölgə axtarılır',
+ 'assistant.step.bucketList': 'Arzular siyahınız oxunur',
+ 'assistant.followUp.atlasWishes': 'Arzular siyahımda nə var?',
+ 'assistant.followUp.atlasLeast': 'Ən az hansı qitəni görmüşəm?',
+ 'assistant.followUp.atlasMark': 'Ziyarət edilmiş kimi işarələ',
+ 'assistant.followUp.atlasWish': 'Arzular siyahıma əlavə et',
+ 'assistant.followUp.atlasPlanWish': 'Növbəti hansı arzunu planlamalıyam?',
+ 'assistant.followUp.atlasAddWish': 'Daha bir arzu əlavə et',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/br/assistantAtlas.ts b/shared/src/i18n/br/assistantAtlas.ts
index 3d8e7daa8..9da659bde 100644
--- a/shared/src/i18n/br/assistantAtlas.ts
+++ b/shared/src/i18n/br/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Seu atlas',
+ 'assistant.card.atlas.planned': '{count} planejados',
+ 'assistant.card.atlas.planned.one': '{count} planejado',
+ 'assistant.card.atlas.factFlown': 'De avião',
+ 'assistant.card.atlas.open': 'Abrir Atlas',
+ 'assistant.card.atlas.nextTrip': 'Próxima viagem: {title} em {count} dias',
+ 'assistant.card.atlas.nextTrip.one': 'Próxima viagem: {title} em {count} dia',
+ 'assistant.card.atlas.nextTripToday': 'Próxima viagem: {title} começa hoje',
+ 'assistant.card.atlas.status.visited': 'Visitado',
+ 'assistant.card.atlas.status.planned': 'Planejado',
+ 'assistant.card.atlas.status.idea': 'Ideia',
+ 'assistant.card.atlas.status.none': 'Fora do seu mapa',
+ 'assistant.card.atlas.status.unknown': 'Suas viagens decidem',
+ 'assistant.card.atlas.dates': 'de {from} a {to}',
+ 'assistant.card.atlas.fromRecordings': 'Das suas gravações',
+ 'assistant.card.atlas.tallyCountries': 'Países visitados',
+ 'assistant.card.atlas.tallyRegions': 'Regiões de {country}',
+ 'assistant.card.atlas.tallyWishes': 'Lista de desejos',
+ 'assistant.card.atlas.reached': 'Alcançado em {date}',
+ 'assistant.card.atlas.duplicate': 'Já está na sua lista',
+ 'assistant.card.atlas.trips': '{count} viagens',
+ 'assistant.card.atlas.trips.one': '{count} viagem',
+ 'assistant.card.atlas.places': '{count} lugares',
+ 'assistant.card.atlas.places.one': '{count} lugar',
+ 'assistant.card.atlas.streak': '{count} anos seguidos',
+ 'assistant.card.atlas.streak.one': '{count} ano seguido',
+ 'assistant.tool.unmark_country_visited': 'Remover um país visitado',
+ 'assistant.tool.mark_region_visited': 'Marcar a região como visitada',
+ 'assistant.tool.unmark_region_visited': 'Remover uma região visitada',
+ 'assistant.tool.create_bucket_list_item': 'Adicionar à lista de desejos',
+ 'assistant.tool.update_bucket_list_item': 'Alterar um desejo da lista',
+ 'assistant.tool.delete_bucket_list_item': 'Remover da lista de desejos',
+ 'assistant.step.atlas': 'Lendo seu atlas',
+ 'assistant.step.atlasCountry': 'Olhando um país',
+ 'assistant.step.regions': 'Lendo suas regiões',
+ 'assistant.step.locateRegion': 'Procurando a região',
+ 'assistant.step.bucketList': 'Lendo sua lista de desejos',
+ 'assistant.followUp.atlasWishes': 'O que tem na minha lista de desejos?',
+ 'assistant.followUp.atlasLeast': 'Qual continente eu menos conheci?',
+ 'assistant.followUp.atlasMark': 'Marque como visitado',
+ 'assistant.followUp.atlasWish': 'Coloque na minha lista de desejos',
+ 'assistant.followUp.atlasPlanWish': 'Qual desejo devo planejar agora?',
+ 'assistant.followUp.atlasAddWish': 'Adicione outro desejo',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/ca/assistantAtlas.ts b/shared/src/i18n/ca/assistantAtlas.ts
index 3d8e7daa8..da2f00c76 100644
--- a/shared/src/i18n/ca/assistantAtlas.ts
+++ b/shared/src/i18n/ca/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'El teu atles',
+ 'assistant.card.atlas.planned': '{count} planificats',
+ 'assistant.card.atlas.planned.one': '{count} planificat',
+ 'assistant.card.atlas.factFlown': 'En avió',
+ 'assistant.card.atlas.open': "Obre l'Atlas",
+ 'assistant.card.atlas.nextTrip': 'Proper viatge: {title} en {count} dies',
+ 'assistant.card.atlas.nextTrip.one': 'Proper viatge: {title} en {count} dia',
+ 'assistant.card.atlas.nextTripToday': 'Proper viatge: {title} comença avui',
+ 'assistant.card.atlas.status.visited': 'Visitat',
+ 'assistant.card.atlas.status.planned': 'Planificat',
+ 'assistant.card.atlas.status.idea': 'Idea', // same-as-en
+ 'assistant.card.atlas.status.none': 'No és al teu mapa',
+ 'assistant.card.atlas.status.unknown': 'Ho decideixen els teus viatges',
+ 'assistant.card.atlas.dates': 'del {from} al {to}',
+ 'assistant.card.atlas.fromRecordings': 'Segons els teus enregistraments',
+ 'assistant.card.atlas.tallyCountries': 'Països visitats',
+ 'assistant.card.atlas.tallyRegions': 'Regions de {country}',
+ 'assistant.card.atlas.tallyWishes': 'Llista de desitjos',
+ 'assistant.card.atlas.reached': 'Assolit el {date}',
+ 'assistant.card.atlas.duplicate': 'Ja és a la teva llista',
+ 'assistant.card.atlas.trips': '{count} viatges',
+ 'assistant.card.atlas.trips.one': '{count} viatge',
+ 'assistant.card.atlas.places': '{count} llocs',
+ 'assistant.card.atlas.places.one': '{count} lloc',
+ 'assistant.card.atlas.streak': '{count} anys seguits',
+ 'assistant.card.atlas.streak.one': '{count} any seguit',
+ 'assistant.tool.unmark_country_visited': 'Treu un país visitat',
+ 'assistant.tool.mark_region_visited': 'Marca la regió com a visitada',
+ 'assistant.tool.unmark_region_visited': 'Treu una regió visitada',
+ 'assistant.tool.create_bucket_list_item': 'Afegeix a la llista de desitjos',
+ 'assistant.tool.update_bucket_list_item': 'Canvia un desig de la llista',
+ 'assistant.tool.delete_bucket_list_item': 'Treu de la llista de desitjos',
+ 'assistant.step.atlas': 'Llegint el teu atles',
+ 'assistant.step.atlasCountry': 'Mirant un país',
+ 'assistant.step.regions': 'Llegint les teves regions',
+ 'assistant.step.locateRegion': 'Buscant la regió',
+ 'assistant.step.bucketList': 'Llegint la teva llista de desitjos',
+ 'assistant.followUp.atlasWishes': 'Què hi ha a la meva llista de desitjos?',
+ 'assistant.followUp.atlasLeast': 'Quin continent he vist menys?',
+ 'assistant.followUp.atlasMark': "Marca'l com a visitat",
+ 'assistant.followUp.atlasWish': "Posa'l a la meva llista de desitjos",
+ 'assistant.followUp.atlasPlanWish': 'Quin desig hauria de planificar ara?',
+ 'assistant.followUp.atlasAddWish': 'Afegeix un altre desig',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/cs/assistantAtlas.ts b/shared/src/i18n/cs/assistantAtlas.ts
index 3d8e7daa8..637772a54 100644
--- a/shared/src/i18n/cs/assistantAtlas.ts
+++ b/shared/src/i18n/cs/assistantAtlas.ts
@@ -1,4 +1,53 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Tvůj atlas',
+ 'assistant.card.atlas.planned': '{count} plánovaných',
+ 'assistant.card.atlas.planned.one': '{count} plánovaná',
+ 'assistant.card.atlas.planned.few': '{count} plánované',
+ 'assistant.card.atlas.factFlown': 'Nalétáno',
+ 'assistant.card.atlas.open': 'Otevřít Atlas',
+ 'assistant.card.atlas.nextTrip': 'Další cesta: {title} za {count} dní',
+ 'assistant.card.atlas.nextTrip.one': 'Další cesta: {title} za {count} den',
+ 'assistant.card.atlas.nextTrip.few': 'Další cesta: {title} za {count} dny',
+ 'assistant.card.atlas.nextTripToday': 'Další cesta: {title} začíná dnes',
+ 'assistant.card.atlas.status.visited': 'Navštíveno',
+ 'assistant.card.atlas.status.planned': 'Plánováno',
+ 'assistant.card.atlas.status.idea': 'Nápad',
+ 'assistant.card.atlas.status.none': 'Není na tvé mapě',
+ 'assistant.card.atlas.status.unknown': 'Rozhodnou tvé cesty',
+ 'assistant.card.atlas.dates': '{from} až {to}',
+ 'assistant.card.atlas.fromRecordings': 'Z tvých záznamů',
+ 'assistant.card.atlas.tallyCountries': 'Navštívené země',
+ 'assistant.card.atlas.tallyRegions': 'Regiony: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Seznam přání',
+ 'assistant.card.atlas.reached': 'Splněno {date}',
+ 'assistant.card.atlas.duplicate': 'Už je na tvém seznamu',
+ 'assistant.card.atlas.trips': '{count} cest',
+ 'assistant.card.atlas.trips.one': '{count} cesta',
+ 'assistant.card.atlas.trips.few': '{count} cesty',
+ 'assistant.card.atlas.places': '{count} míst',
+ 'assistant.card.atlas.places.one': '{count} místo',
+ 'assistant.card.atlas.places.few': '{count} místa',
+ 'assistant.card.atlas.streak': '{count} let v řadě',
+ 'assistant.card.atlas.streak.one': '{count} rok v řadě',
+ 'assistant.card.atlas.streak.few': '{count} roky v řadě',
+ 'assistant.tool.unmark_country_visited': 'Odebrat navštívenou zemi',
+ 'assistant.tool.mark_region_visited': 'Označit region jako navštívený',
+ 'assistant.tool.unmark_region_visited': 'Odebrat navštívený region',
+ 'assistant.tool.create_bucket_list_item': 'Přidat na seznam přání',
+ 'assistant.tool.update_bucket_list_item': 'Změnit přání na seznamu',
+ 'assistant.tool.delete_bucket_list_item': 'Odebrat ze seznamu přání',
+ 'assistant.step.atlas': 'Čtu tvůj atlas',
+ 'assistant.step.atlasCountry': 'Prohlížím zemi',
+ 'assistant.step.regions': 'Čtu tvé regiony',
+ 'assistant.step.locateRegion': 'Hledám region',
+ 'assistant.step.bucketList': 'Čtu tvůj seznam přání',
+ 'assistant.followUp.atlasWishes': 'Co mám na seznamu přání?',
+ 'assistant.followUp.atlasLeast': 'Který kontinent znám nejméně?',
+ 'assistant.followUp.atlasMark': 'Označ ji jako navštívenou',
+ 'assistant.followUp.atlasWish': 'Dej ji na můj seznam přání',
+ 'assistant.followUp.atlasPlanWish': 'Které přání mám naplánovat jako další?',
+ 'assistant.followUp.atlasAddWish': 'Přidej další přání',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/de/assistantAtlas.ts b/shared/src/i18n/de/assistantAtlas.ts
index 3d8e7daa8..a650ee37f 100644
--- a/shared/src/i18n/de/assistantAtlas.ts
+++ b/shared/src/i18n/de/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Dein Atlas',
+ 'assistant.card.atlas.planned': '{count} geplant',
+ 'assistant.card.atlas.planned.one': '{count} geplant',
+ 'assistant.card.atlas.factFlown': 'Geflogen',
+ 'assistant.card.atlas.open': 'Atlas öffnen',
+ 'assistant.card.atlas.nextTrip': 'Nächste Reise: {title} in {count} Tagen',
+ 'assistant.card.atlas.nextTrip.one': 'Nächste Reise: {title} in {count} Tag',
+ 'assistant.card.atlas.nextTripToday': 'Nächste Reise: {title} beginnt heute',
+ 'assistant.card.atlas.status.visited': 'Besucht',
+ 'assistant.card.atlas.status.planned': 'Geplant',
+ 'assistant.card.atlas.status.idea': 'Idee',
+ 'assistant.card.atlas.status.none': 'Nicht auf deiner Karte',
+ 'assistant.card.atlas.status.unknown': 'Deine Reisen entscheiden',
+ 'assistant.card.atlas.dates': '{from} bis {to}',
+ 'assistant.card.atlas.fromRecordings': 'Aus deinen Aufzeichnungen',
+ 'assistant.card.atlas.tallyCountries': 'Besuchte Länder',
+ 'assistant.card.atlas.tallyRegions': 'Regionen in {country}',
+ 'assistant.card.atlas.tallyWishes': 'Wunschliste',
+ 'assistant.card.atlas.reached': 'Erreicht am {date}',
+ 'assistant.card.atlas.duplicate': 'Schon auf deiner Liste',
+ 'assistant.card.atlas.trips': '{count} Reisen',
+ 'assistant.card.atlas.trips.one': '{count} Reise',
+ 'assistant.card.atlas.places': '{count} Orte',
+ 'assistant.card.atlas.places.one': '{count} Ort',
+ 'assistant.card.atlas.streak': '{count} Jahre in Folge',
+ 'assistant.card.atlas.streak.one': '{count} Jahr in Folge',
+ 'assistant.tool.unmark_country_visited': 'Besuchtes Land entfernen',
+ 'assistant.tool.mark_region_visited': 'Region als besucht markieren',
+ 'assistant.tool.unmark_region_visited': 'Besuchte Region entfernen',
+ 'assistant.tool.create_bucket_list_item': 'Zur Wunschliste hinzufügen',
+ 'assistant.tool.update_bucket_list_item': 'Wunsch auf der Wunschliste ändern',
+ 'assistant.tool.delete_bucket_list_item': 'Von der Wunschliste entfernen',
+ 'assistant.step.atlas': 'Liest deinen Atlas',
+ 'assistant.step.atlasCountry': 'Sieht sich ein Land an',
+ 'assistant.step.regions': 'Liest deine Regionen',
+ 'assistant.step.locateRegion': 'Sucht die Region',
+ 'assistant.step.bucketList': 'Liest deine Wunschliste',
+ 'assistant.followUp.atlasWishes': 'Was steht auf meiner Wunschliste?',
+ 'assistant.followUp.atlasLeast': 'Von welchem Kontinent habe ich am wenigsten gesehen?',
+ 'assistant.followUp.atlasMark': 'Markiere es als besucht',
+ 'assistant.followUp.atlasWish': 'Setz es auf meine Wunschliste',
+ 'assistant.followUp.atlasPlanWish': 'Welchen Wunsch sollte ich als Nächstes planen?',
+ 'assistant.followUp.atlasAddWish': 'Noch einen Wunsch hinzufügen',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/en/assistantAtlas.ts b/shared/src/i18n/en/assistantAtlas.ts
index 3d8e7daa8..018f917f0 100644
--- a/shared/src/i18n/en/assistantAtlas.ts
+++ b/shared/src/i18n/en/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Your atlas',
+ 'assistant.card.atlas.planned': '{count} planned',
+ 'assistant.card.atlas.planned.one': '{count} planned',
+ 'assistant.card.atlas.factFlown': 'Flown',
+ 'assistant.card.atlas.open': 'Open Atlas',
+ 'assistant.card.atlas.nextTrip': 'Next trip: {title} in {count} days',
+ 'assistant.card.atlas.nextTrip.one': 'Next trip: {title} in {count} day',
+ 'assistant.card.atlas.nextTripToday': 'Next trip: {title} starts today',
+ 'assistant.card.atlas.status.visited': 'Visited',
+ 'assistant.card.atlas.status.planned': 'Planned',
+ 'assistant.card.atlas.status.idea': 'Idea',
+ 'assistant.card.atlas.status.none': 'Not on your map',
+ 'assistant.card.atlas.status.unknown': 'Your trips decide',
+ 'assistant.card.atlas.dates': '{from} to {to}',
+ 'assistant.card.atlas.fromRecordings': 'From your recordings',
+ 'assistant.card.atlas.tallyCountries': 'Countries visited',
+ 'assistant.card.atlas.tallyRegions': 'Regions in {country}',
+ 'assistant.card.atlas.tallyWishes': 'Bucket list',
+ 'assistant.card.atlas.reached': 'Reached on {date}',
+ 'assistant.card.atlas.duplicate': 'Already on your list',
+ 'assistant.card.atlas.trips': '{count} trips',
+ 'assistant.card.atlas.trips.one': '{count} trip',
+ 'assistant.card.atlas.places': '{count} places',
+ 'assistant.card.atlas.places.one': '{count} place',
+ 'assistant.card.atlas.streak': '{count} years in a row',
+ 'assistant.card.atlas.streak.one': '{count} year in a row',
+ 'assistant.tool.unmark_country_visited': 'Remove a visited country',
+ 'assistant.tool.mark_region_visited': 'Mark the region as visited',
+ 'assistant.tool.unmark_region_visited': 'Remove a visited region',
+ 'assistant.tool.create_bucket_list_item': 'Add to the bucket list',
+ 'assistant.tool.update_bucket_list_item': 'Change a bucket list wish',
+ 'assistant.tool.delete_bucket_list_item': 'Remove from the bucket list',
+ 'assistant.step.atlas': 'Reading your atlas',
+ 'assistant.step.atlasCountry': 'Looking at a country',
+ 'assistant.step.regions': 'Reading your regions',
+ 'assistant.step.locateRegion': 'Finding the region',
+ 'assistant.step.bucketList': 'Reading your bucket list',
+ 'assistant.followUp.atlasWishes': 'What is on my bucket list?',
+ 'assistant.followUp.atlasLeast': 'Which continent have I seen least of?',
+ 'assistant.followUp.atlasMark': 'Mark it as visited',
+ 'assistant.followUp.atlasWish': 'Put it on my bucket list',
+ 'assistant.followUp.atlasPlanWish': 'Which wish should I plan next?',
+ 'assistant.followUp.atlasAddWish': 'Add another wish',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/es/assistantAtlas.ts b/shared/src/i18n/es/assistantAtlas.ts
index 3d8e7daa8..91314eae1 100644
--- a/shared/src/i18n/es/assistantAtlas.ts
+++ b/shared/src/i18n/es/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Tu atlas',
+ 'assistant.card.atlas.planned': '{count} planeados',
+ 'assistant.card.atlas.planned.one': '{count} planeado',
+ 'assistant.card.atlas.factFlown': 'En avión',
+ 'assistant.card.atlas.open': 'Abrir Atlas',
+ 'assistant.card.atlas.nextTrip': 'Próximo viaje: {title} en {count} días',
+ 'assistant.card.atlas.nextTrip.one': 'Próximo viaje: {title} en {count} día',
+ 'assistant.card.atlas.nextTripToday': 'Próximo viaje: {title} empieza hoy',
+ 'assistant.card.atlas.status.visited': 'Visitado',
+ 'assistant.card.atlas.status.planned': 'Planeado',
+ 'assistant.card.atlas.status.idea': 'Idea', // same-as-en
+ 'assistant.card.atlas.status.none': 'No está en tu mapa',
+ 'assistant.card.atlas.status.unknown': 'Lo deciden tus viajes',
+ 'assistant.card.atlas.dates': 'del {from} al {to}',
+ 'assistant.card.atlas.fromRecordings': 'Según tus grabaciones',
+ 'assistant.card.atlas.tallyCountries': 'Países visitados',
+ 'assistant.card.atlas.tallyRegions': 'Regiones de {country}',
+ 'assistant.card.atlas.tallyWishes': 'Lista de deseos',
+ 'assistant.card.atlas.reached': 'Cumplido el {date}',
+ 'assistant.card.atlas.duplicate': 'Ya está en tu lista',
+ 'assistant.card.atlas.trips': '{count} viajes',
+ 'assistant.card.atlas.trips.one': '{count} viaje',
+ 'assistant.card.atlas.places': '{count} lugares',
+ 'assistant.card.atlas.places.one': '{count} lugar',
+ 'assistant.card.atlas.streak': '{count} años seguidos',
+ 'assistant.card.atlas.streak.one': '{count} año seguido',
+ 'assistant.tool.unmark_country_visited': 'Quitar un país visitado',
+ 'assistant.tool.mark_region_visited': 'Marcar la región como visitada',
+ 'assistant.tool.unmark_region_visited': 'Quitar una región visitada',
+ 'assistant.tool.create_bucket_list_item': 'Añadir a la lista de deseos',
+ 'assistant.tool.update_bucket_list_item': 'Cambiar un deseo de la lista',
+ 'assistant.tool.delete_bucket_list_item': 'Quitar de la lista de deseos',
+ 'assistant.step.atlas': 'Leyendo tu atlas',
+ 'assistant.step.atlasCountry': 'Mirando un país',
+ 'assistant.step.regions': 'Leyendo tus regiones',
+ 'assistant.step.locateRegion': 'Buscando la región',
+ 'assistant.step.bucketList': 'Leyendo tu lista de deseos',
+ 'assistant.followUp.atlasWishes': '¿Qué hay en mi lista de deseos?',
+ 'assistant.followUp.atlasLeast': '¿Qué continente he visto menos?',
+ 'assistant.followUp.atlasMark': 'Márcalo como visitado',
+ 'assistant.followUp.atlasWish': 'Ponlo en mi lista de deseos',
+ 'assistant.followUp.atlasPlanWish': '¿Qué deseo debería planear ahora?',
+ 'assistant.followUp.atlasAddWish': 'Añade otro deseo',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/et/assistantAtlas.ts b/shared/src/i18n/et/assistantAtlas.ts
index 3d8e7daa8..32fd38912 100644
--- a/shared/src/i18n/et/assistantAtlas.ts
+++ b/shared/src/i18n/et/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Sinu atlas',
+ 'assistant.card.atlas.planned': '{count} plaanis',
+ 'assistant.card.atlas.planned.one': '{count} plaanis',
+ 'assistant.card.atlas.factFlown': 'Lennatud',
+ 'assistant.card.atlas.open': 'Ava Atlas',
+ 'assistant.card.atlas.nextTrip': 'Järgmine reis: {title} {count} päeva pärast',
+ 'assistant.card.atlas.nextTrip.one': 'Järgmine reis: {title} {count} päeva pärast',
+ 'assistant.card.atlas.nextTripToday': 'Järgmine reis: {title} algab täna',
+ 'assistant.card.atlas.status.visited': 'Külastatud',
+ 'assistant.card.atlas.status.planned': 'Plaanis',
+ 'assistant.card.atlas.status.idea': 'Mõte',
+ 'assistant.card.atlas.status.none': 'Pole sinu kaardil',
+ 'assistant.card.atlas.status.unknown': 'Sinu reisid otsustavad',
+ 'assistant.card.atlas.dates': '{from} kuni {to}',
+ 'assistant.card.atlas.fromRecordings': 'Sinu salvestustest',
+ 'assistant.card.atlas.tallyCountries': 'Külastatud riigid',
+ 'assistant.card.atlas.tallyRegions': 'Piirkonnad: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Soovinimekiri',
+ 'assistant.card.atlas.reached': 'Täitunud {date}',
+ 'assistant.card.atlas.duplicate': 'Juba sinu nimekirjas',
+ 'assistant.card.atlas.trips': '{count} reisi',
+ 'assistant.card.atlas.trips.one': '{count} reis',
+ 'assistant.card.atlas.places': '{count} kohta',
+ 'assistant.card.atlas.places.one': '{count} koht',
+ 'assistant.card.atlas.streak': '{count} aastat järjest',
+ 'assistant.card.atlas.streak.one': '{count} aasta järjest',
+ 'assistant.tool.unmark_country_visited': 'Eemalda külastatud riik',
+ 'assistant.tool.mark_region_visited': 'Märgi piirkond külastatuks',
+ 'assistant.tool.unmark_region_visited': 'Eemalda külastatud piirkond',
+ 'assistant.tool.create_bucket_list_item': 'Lisa soovinimekirja',
+ 'assistant.tool.update_bucket_list_item': 'Muuda soovi nimekirjas',
+ 'assistant.tool.delete_bucket_list_item': 'Eemalda soovinimekirjast',
+ 'assistant.step.atlas': 'Loen sinu atlast',
+ 'assistant.step.atlasCountry': 'Vaatan riiki',
+ 'assistant.step.regions': 'Loen sinu piirkondi',
+ 'assistant.step.locateRegion': 'Otsin piirkonda',
+ 'assistant.step.bucketList': 'Loen sinu soovinimekirja',
+ 'assistant.followUp.atlasWishes': 'Mis on mu soovinimekirjas?',
+ 'assistant.followUp.atlasLeast': 'Millist mandrit olen kõige vähem näinud?',
+ 'assistant.followUp.atlasMark': 'Märgi see külastatuks',
+ 'assistant.followUp.atlasWish': 'Pane see mu soovinimekirja',
+ 'assistant.followUp.atlasPlanWish': 'Millist soovi peaksin järgmisena planeerima?',
+ 'assistant.followUp.atlasAddWish': 'Lisa veel üks soov',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/fr/assistantAtlas.ts b/shared/src/i18n/fr/assistantAtlas.ts
index 3d8e7daa8..6bfd3656d 100644
--- a/shared/src/i18n/fr/assistantAtlas.ts
+++ b/shared/src/i18n/fr/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Votre atlas',
+ 'assistant.card.atlas.planned': '{count} prévus',
+ 'assistant.card.atlas.planned.one': '{count} prévu',
+ 'assistant.card.atlas.factFlown': 'En avion',
+ 'assistant.card.atlas.open': "Ouvrir l'Atlas",
+ 'assistant.card.atlas.nextTrip': 'Prochain voyage : {title} dans {count} jours',
+ 'assistant.card.atlas.nextTrip.one': 'Prochain voyage : {title} dans {count} jour',
+ 'assistant.card.atlas.nextTripToday': "Prochain voyage : {title} commence aujourd'hui",
+ 'assistant.card.atlas.status.visited': 'Visité',
+ 'assistant.card.atlas.status.planned': 'Prévu',
+ 'assistant.card.atlas.status.idea': 'Idée',
+ 'assistant.card.atlas.status.none': 'Pas sur votre carte',
+ 'assistant.card.atlas.status.unknown': 'Vos voyages décident',
+ 'assistant.card.atlas.dates': 'du {from} au {to}',
+ 'assistant.card.atlas.fromRecordings': "D'après vos enregistrements",
+ 'assistant.card.atlas.tallyCountries': 'Pays visités',
+ 'assistant.card.atlas.tallyRegions': 'Régions de {country}',
+ 'assistant.card.atlas.tallyWishes': 'Liste de souhaits',
+ 'assistant.card.atlas.reached': 'Atteint le {date}',
+ 'assistant.card.atlas.duplicate': 'Déjà sur votre liste',
+ 'assistant.card.atlas.trips': '{count} voyages',
+ 'assistant.card.atlas.trips.one': '{count} voyage',
+ 'assistant.card.atlas.places': '{count} lieux',
+ 'assistant.card.atlas.places.one': '{count} lieu',
+ 'assistant.card.atlas.streak': '{count} années consécutives',
+ 'assistant.card.atlas.streak.one': '{count} année consécutive',
+ 'assistant.tool.unmark_country_visited': 'Retirer un pays visité',
+ 'assistant.tool.mark_region_visited': 'Marquer la région comme visitée',
+ 'assistant.tool.unmark_region_visited': 'Retirer une région visitée',
+ 'assistant.tool.create_bucket_list_item': 'Ajouter à la liste de souhaits',
+ 'assistant.tool.update_bucket_list_item': 'Modifier un souhait de la liste',
+ 'assistant.tool.delete_bucket_list_item': 'Retirer de la liste de souhaits',
+ 'assistant.step.atlas': 'Lecture de votre atlas',
+ 'assistant.step.atlasCountry': "Examen d'un pays",
+ 'assistant.step.regions': 'Lecture de vos régions',
+ 'assistant.step.locateRegion': 'Recherche de la région',
+ 'assistant.step.bucketList': 'Lecture de votre liste de souhaits',
+ 'assistant.followUp.atlasWishes': "Qu'y a-t-il sur ma liste de souhaits ?",
+ 'assistant.followUp.atlasLeast': 'Quel continent ai-je le moins visité ?',
+ 'assistant.followUp.atlasMark': 'Marque-le comme visité',
+ 'assistant.followUp.atlasWish': 'Ajoute-le à ma liste de souhaits',
+ 'assistant.followUp.atlasPlanWish': 'Quel souhait devrais-je planifier ensuite ?',
+ 'assistant.followUp.atlasAddWish': 'Ajoute un autre souhait',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/gr/assistantAtlas.ts b/shared/src/i18n/gr/assistantAtlas.ts
index 3d8e7daa8..af6a1a48b 100644
--- a/shared/src/i18n/gr/assistantAtlas.ts
+++ b/shared/src/i18n/gr/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Ο άτλαντάς σας',
+ 'assistant.card.atlas.planned': '{count} προγραμματισμένες',
+ 'assistant.card.atlas.planned.one': '{count} προγραμματισμένη',
+ 'assistant.card.atlas.factFlown': 'Πτήσεις',
+ 'assistant.card.atlas.open': 'Άνοιγμα Άτλαντα',
+ 'assistant.card.atlas.nextTrip': 'Επόμενο ταξίδι: {title} σε {count} ημέρες',
+ 'assistant.card.atlas.nextTrip.one': 'Επόμενο ταξίδι: {title} σε {count} ημέρα',
+ 'assistant.card.atlas.nextTripToday': 'Επόμενο ταξίδι: {title} ξεκινά σήμερα',
+ 'assistant.card.atlas.status.visited': 'Επισκέφθηκε',
+ 'assistant.card.atlas.status.planned': 'Προγραμματισμένη',
+ 'assistant.card.atlas.status.idea': 'Ιδέα',
+ 'assistant.card.atlas.status.none': 'Δεν είναι στον χάρτη σας',
+ 'assistant.card.atlas.status.unknown': 'Αποφασίζουν τα ταξίδια σας',
+ 'assistant.card.atlas.dates': '{from} έως {to}',
+ 'assistant.card.atlas.fromRecordings': 'Από τις καταγραφές σας',
+ 'assistant.card.atlas.tallyCountries': 'Επισκεφθείσες χώρες',
+ 'assistant.card.atlas.tallyRegions': 'Περιοχές: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Λίστα επιθυμιών',
+ 'assistant.card.atlas.reached': 'Επιτεύχθηκε στις {date}',
+ 'assistant.card.atlas.duplicate': 'Υπάρχει ήδη στη λίστα σας',
+ 'assistant.card.atlas.trips': '{count} ταξίδια',
+ 'assistant.card.atlas.trips.one': '{count} ταξίδι',
+ 'assistant.card.atlas.places': '{count} μέρη',
+ 'assistant.card.atlas.places.one': '{count} μέρος',
+ 'assistant.card.atlas.streak': '{count} έτη στη σειρά',
+ 'assistant.card.atlas.streak.one': '{count} έτος στη σειρά',
+ 'assistant.tool.unmark_country_visited': 'Αφαίρεση χώρας που επισκεφθήκατε',
+ 'assistant.tool.mark_region_visited': 'Σήμανση περιοχής ως επισκεφθείσας',
+ 'assistant.tool.unmark_region_visited': 'Αφαίρεση περιοχής που επισκεφθήκατε',
+ 'assistant.tool.create_bucket_list_item': 'Προσθήκη στη λίστα επιθυμιών',
+ 'assistant.tool.update_bucket_list_item': 'Αλλαγή επιθυμίας της λίστας',
+ 'assistant.tool.delete_bucket_list_item': 'Αφαίρεση από τη λίστα επιθυμιών',
+ 'assistant.step.atlas': 'Διαβάζω τον άτλαντά σας',
+ 'assistant.step.atlasCountry': 'Κοιτάζω μια χώρα',
+ 'assistant.step.regions': 'Διαβάζω τις περιοχές σας',
+ 'assistant.step.locateRegion': 'Βρίσκω την περιοχή',
+ 'assistant.step.bucketList': 'Διαβάζω τη λίστα επιθυμιών σας',
+ 'assistant.followUp.atlasWishes': 'Τι έχω στη λίστα επιθυμιών μου;',
+ 'assistant.followUp.atlasLeast': 'Ποια ήπειρο έχω δει λιγότερο;',
+ 'assistant.followUp.atlasMark': 'Σημείωσέ τη ως επισκεφθείσα',
+ 'assistant.followUp.atlasWish': 'Βάλ’ τη στη λίστα επιθυμιών μου',
+ 'assistant.followUp.atlasPlanWish': 'Ποια επιθυμία να σχεδιάσω μετά;',
+ 'assistant.followUp.atlasAddWish': 'Πρόσθεσε άλλη μία επιθυμία',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/hu/assistantAtlas.ts b/shared/src/i18n/hu/assistantAtlas.ts
index 3d8e7daa8..f002da21f 100644
--- a/shared/src/i18n/hu/assistantAtlas.ts
+++ b/shared/src/i18n/hu/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Az atlaszod',
+ 'assistant.card.atlas.planned': '{count} tervezett',
+ 'assistant.card.atlas.planned.one': '{count} tervezett',
+ 'assistant.card.atlas.factFlown': 'Repült',
+ 'assistant.card.atlas.open': 'Atlas megnyitása',
+ 'assistant.card.atlas.nextTrip': 'Következő utazás: {title}, {count} nap múlva',
+ 'assistant.card.atlas.nextTrip.one': 'Következő utazás: {title}, {count} nap múlva',
+ 'assistant.card.atlas.nextTripToday': 'Következő utazás: {title} ma kezdődik',
+ 'assistant.card.atlas.status.visited': 'Meglátogatva',
+ 'assistant.card.atlas.status.planned': 'Tervezett',
+ 'assistant.card.atlas.status.idea': 'Ötlet',
+ 'assistant.card.atlas.status.none': 'Nincs a térképeden',
+ 'assistant.card.atlas.status.unknown': 'Az utazásaid döntenek',
+ 'assistant.card.atlas.dates': '{from} és {to} között',
+ 'assistant.card.atlas.fromRecordings': 'A rögzítéseidből',
+ 'assistant.card.atlas.tallyCountries': 'Meglátogatott országok',
+ 'assistant.card.atlas.tallyRegions': 'Régiók: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Bakancslista',
+ 'assistant.card.atlas.reached': 'Teljesült: {date}',
+ 'assistant.card.atlas.duplicate': 'Már rajta van a listádon',
+ 'assistant.card.atlas.trips': '{count} utazás',
+ 'assistant.card.atlas.trips.one': '{count} utazás',
+ 'assistant.card.atlas.places': '{count} hely',
+ 'assistant.card.atlas.places.one': '{count} hely',
+ 'assistant.card.atlas.streak': '{count} egymást követő év',
+ 'assistant.card.atlas.streak.one': '{count} egymást követő év',
+ 'assistant.tool.unmark_country_visited': 'Meglátogatott ország eltávolítása',
+ 'assistant.tool.mark_region_visited': 'Régió megjelölése meglátogatottként',
+ 'assistant.tool.unmark_region_visited': 'Meglátogatott régió eltávolítása',
+ 'assistant.tool.create_bucket_list_item': 'Hozzáadás a bakancslistához',
+ 'assistant.tool.update_bucket_list_item': 'Bakancslistás kívánság módosítása',
+ 'assistant.tool.delete_bucket_list_item': 'Eltávolítás a bakancslistáról',
+ 'assistant.step.atlas': 'Az atlaszod olvasása',
+ 'assistant.step.atlasCountry': 'Egy ország megnézése',
+ 'assistant.step.regions': 'A régióid olvasása',
+ 'assistant.step.locateRegion': 'A régió megkeresése',
+ 'assistant.step.bucketList': 'A bakancslistád olvasása',
+ 'assistant.followUp.atlasWishes': 'Mi van a bakancslistámon?',
+ 'assistant.followUp.atlasLeast': 'Melyik kontinensből láttam a legkevesebbet?',
+ 'assistant.followUp.atlasMark': 'Jelöld meg meglátogatottként',
+ 'assistant.followUp.atlasWish': 'Tedd fel a bakancslistámra',
+ 'assistant.followUp.atlasPlanWish': 'Melyik kívánságot tervezzem meg következőnek?',
+ 'assistant.followUp.atlasAddWish': 'Adj hozzá még egy kívánságot',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/id/assistantAtlas.ts b/shared/src/i18n/id/assistantAtlas.ts
index 3d8e7daa8..0e0833b7f 100644
--- a/shared/src/i18n/id/assistantAtlas.ts
+++ b/shared/src/i18n/id/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Atlasmu',
+ 'assistant.card.atlas.planned': '{count} direncanakan',
+ 'assistant.card.atlas.factFlown': 'Terbang',
+ 'assistant.card.atlas.open': 'Buka Atlas',
+ 'assistant.card.atlas.nextTrip': 'Perjalanan berikutnya: {title} dalam {count} hari',
+ 'assistant.card.atlas.nextTripToday': 'Perjalanan berikutnya: {title} mulai hari ini',
+ 'assistant.card.atlas.status.visited': 'Dikunjungi',
+ 'assistant.card.atlas.status.planned': 'Direncanakan',
+ 'assistant.card.atlas.status.idea': 'Ide',
+ 'assistant.card.atlas.status.none': 'Tidak ada di petamu',
+ 'assistant.card.atlas.status.unknown': 'Perjalananmu yang menentukan',
+ 'assistant.card.atlas.dates': '{from} sampai {to}',
+ 'assistant.card.atlas.fromRecordings': 'Dari rekamanmu',
+ 'assistant.card.atlas.tallyCountries': 'Negara yang dikunjungi',
+ 'assistant.card.atlas.tallyRegions': 'Wilayah di {country}',
+ 'assistant.card.atlas.tallyWishes': 'Daftar impian',
+ 'assistant.card.atlas.reached': 'Tercapai pada {date}',
+ 'assistant.card.atlas.duplicate': 'Sudah ada di daftarmu',
+ 'assistant.card.atlas.trips': '{count} perjalanan',
+ 'assistant.card.atlas.places': '{count} tempat',
+ 'assistant.card.atlas.streak': '{count} tahun berturut-turut',
+ 'assistant.tool.unmark_country_visited': 'Hapus negara yang dikunjungi',
+ 'assistant.tool.mark_region_visited': 'Tandai wilayah sebagai dikunjungi',
+ 'assistant.tool.unmark_region_visited': 'Hapus wilayah yang dikunjungi',
+ 'assistant.tool.create_bucket_list_item': 'Tambahkan ke daftar impian',
+ 'assistant.tool.update_bucket_list_item': 'Ubah impian di daftar',
+ 'assistant.tool.delete_bucket_list_item': 'Hapus dari daftar impian',
+ 'assistant.step.atlas': 'Membaca atlasmu',
+ 'assistant.step.atlasCountry': 'Melihat sebuah negara',
+ 'assistant.step.regions': 'Membaca wilayahmu',
+ 'assistant.step.locateRegion': 'Mencari wilayah',
+ 'assistant.step.bucketList': 'Membaca daftar impianmu',
+ 'assistant.followUp.atlasWishes': 'Apa saja yang ada di daftar impianku?',
+ 'assistant.followUp.atlasLeast': 'Benua mana yang paling sedikit kulihat?',
+ 'assistant.followUp.atlasMark': 'Tandai sebagai dikunjungi',
+ 'assistant.followUp.atlasWish': 'Masukkan ke daftar impianku',
+ 'assistant.followUp.atlasPlanWish': 'Impian mana yang sebaiknya kurencanakan berikutnya?',
+ 'assistant.followUp.atlasAddWish': 'Tambahkan impian lain',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/it/assistantAtlas.ts b/shared/src/i18n/it/assistantAtlas.ts
index 3d8e7daa8..9eae70b42 100644
--- a/shared/src/i18n/it/assistantAtlas.ts
+++ b/shared/src/i18n/it/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Il tuo atlante',
+ 'assistant.card.atlas.planned': '{count} in programma',
+ 'assistant.card.atlas.planned.one': '{count} in programma',
+ 'assistant.card.atlas.factFlown': 'In volo',
+ 'assistant.card.atlas.open': 'Apri Atlas',
+ 'assistant.card.atlas.nextTrip': 'Prossimo viaggio: {title} tra {count} giorni',
+ 'assistant.card.atlas.nextTrip.one': 'Prossimo viaggio: {title} tra {count} giorno',
+ 'assistant.card.atlas.nextTripToday': 'Prossimo viaggio: {title} inizia oggi',
+ 'assistant.card.atlas.status.visited': 'Visitato',
+ 'assistant.card.atlas.status.planned': 'In programma',
+ 'assistant.card.atlas.status.idea': 'Idea', // same-as-en
+ 'assistant.card.atlas.status.none': 'Non è sulla tua mappa',
+ 'assistant.card.atlas.status.unknown': 'Decidono i tuoi viaggi',
+ 'assistant.card.atlas.dates': 'dal {from} al {to}',
+ 'assistant.card.atlas.fromRecordings': 'Dalle tue registrazioni',
+ 'assistant.card.atlas.tallyCountries': 'Paesi visitati',
+ 'assistant.card.atlas.tallyRegions': 'Regioni in {country}',
+ 'assistant.card.atlas.tallyWishes': 'Lista desideri',
+ 'assistant.card.atlas.reached': 'Raggiunto il {date}',
+ 'assistant.card.atlas.duplicate': 'Già nella tua lista',
+ 'assistant.card.atlas.trips': '{count} viaggi',
+ 'assistant.card.atlas.trips.one': '{count} viaggio',
+ 'assistant.card.atlas.places': '{count} luoghi',
+ 'assistant.card.atlas.places.one': '{count} luogo',
+ 'assistant.card.atlas.streak': '{count} anni consecutivi',
+ 'assistant.card.atlas.streak.one': '{count} anno consecutivo',
+ 'assistant.tool.unmark_country_visited': 'Rimuovi un paese visitato',
+ 'assistant.tool.mark_region_visited': 'Segna la regione come visitata',
+ 'assistant.tool.unmark_region_visited': 'Rimuovi una regione visitata',
+ 'assistant.tool.create_bucket_list_item': 'Aggiungi alla lista desideri',
+ 'assistant.tool.update_bucket_list_item': 'Modifica un desiderio della lista',
+ 'assistant.tool.delete_bucket_list_item': 'Rimuovi dalla lista desideri',
+ 'assistant.step.atlas': 'Leggo il tuo atlante',
+ 'assistant.step.atlasCountry': 'Guardo un paese',
+ 'assistant.step.regions': 'Leggo le tue regioni',
+ 'assistant.step.locateRegion': 'Cerco la regione',
+ 'assistant.step.bucketList': 'Leggo la tua lista desideri',
+ 'assistant.followUp.atlasWishes': "Cosa c'è nella mia lista desideri?",
+ 'assistant.followUp.atlasLeast': 'Quale continente ho visto di meno?',
+ 'assistant.followUp.atlasMark': 'Segnalo come visitato',
+ 'assistant.followUp.atlasWish': 'Mettilo nella mia lista desideri',
+ 'assistant.followUp.atlasPlanWish': 'Quale desiderio dovrei pianificare adesso?',
+ 'assistant.followUp.atlasAddWish': 'Aggiungi un altro desiderio',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/ja/assistantAtlas.ts b/shared/src/i18n/ja/assistantAtlas.ts
index 3d8e7daa8..0ec68fe74 100644
--- a/shared/src/i18n/ja/assistantAtlas.ts
+++ b/shared/src/i18n/ja/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'あなたのアトラス',
+ 'assistant.card.atlas.planned': '{count} か国予定',
+ 'assistant.card.atlas.factFlown': '飛行距離',
+ 'assistant.card.atlas.open': 'アトラスを開く',
+ 'assistant.card.atlas.nextTrip': '次の旅: {title} まであと {count} 日',
+ 'assistant.card.atlas.nextTripToday': '次の旅: {title} は今日から',
+ 'assistant.card.atlas.status.visited': '訪問済み',
+ 'assistant.card.atlas.status.planned': '予定',
+ 'assistant.card.atlas.status.idea': 'アイデア',
+ 'assistant.card.atlas.status.none': '地図にありません',
+ 'assistant.card.atlas.status.unknown': '旅の内容で決まります',
+ 'assistant.card.atlas.dates': '{from}〜{to}',
+ 'assistant.card.atlas.fromRecordings': '記録より',
+ 'assistant.card.atlas.tallyCountries': '訪問した国',
+ 'assistant.card.atlas.tallyRegions': '{country} の地域',
+ 'assistant.card.atlas.tallyWishes': '行きたいリスト',
+ 'assistant.card.atlas.reached': '{date} に達成',
+ 'assistant.card.atlas.duplicate': 'すでにリストにあります',
+ 'assistant.card.atlas.trips': '{count} 回の旅行',
+ 'assistant.card.atlas.places': '{count} か所',
+ 'assistant.card.atlas.streak': '{count} 年連続',
+ 'assistant.tool.unmark_country_visited': '訪問済みの国を外す',
+ 'assistant.tool.mark_region_visited': '地域を訪問済みにする',
+ 'assistant.tool.unmark_region_visited': '訪問済みの地域を外す',
+ 'assistant.tool.create_bucket_list_item': '行きたいリストに追加',
+ 'assistant.tool.update_bucket_list_item': '行きたいリストの項目を変更',
+ 'assistant.tool.delete_bucket_list_item': '行きたいリストから外す',
+ 'assistant.step.atlas': 'アトラスを読んでいます',
+ 'assistant.step.atlasCountry': '国を見ています',
+ 'assistant.step.regions': '地域を読んでいます',
+ 'assistant.step.locateRegion': '地域を探しています',
+ 'assistant.step.bucketList': '行きたいリストを読んでいます',
+ 'assistant.followUp.atlasWishes': '行きたいリストには何がある?',
+ 'assistant.followUp.atlasLeast': 'いちばん見ていない大陸はどこ?',
+ 'assistant.followUp.atlasMark': '訪問済みにして',
+ 'assistant.followUp.atlasWish': '行きたいリストに入れて',
+ 'assistant.followUp.atlasPlanWish': '次に計画すべき行きたい場所は?',
+ 'assistant.followUp.atlasAddWish': 'もう一つ追加して',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/ko/assistantAtlas.ts b/shared/src/i18n/ko/assistantAtlas.ts
index 3d8e7daa8..5548d2d63 100644
--- a/shared/src/i18n/ko/assistantAtlas.ts
+++ b/shared/src/i18n/ko/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': '내 아틀라스',
+ 'assistant.card.atlas.planned': '{count}개국 예정',
+ 'assistant.card.atlas.factFlown': '비행 거리',
+ 'assistant.card.atlas.open': '아틀라스 열기',
+ 'assistant.card.atlas.nextTrip': '다음 여행: {title}까지 {count}일',
+ 'assistant.card.atlas.nextTripToday': '다음 여행: {title} 오늘 시작',
+ 'assistant.card.atlas.status.visited': '방문함',
+ 'assistant.card.atlas.status.planned': '예정',
+ 'assistant.card.atlas.status.idea': '아이디어',
+ 'assistant.card.atlas.status.none': '지도에 없음',
+ 'assistant.card.atlas.status.unknown': '여행 내용에 따라 결정됨',
+ 'assistant.card.atlas.dates': '{from}~{to}',
+ 'assistant.card.atlas.fromRecordings': '기록에서',
+ 'assistant.card.atlas.tallyCountries': '방문한 나라',
+ 'assistant.card.atlas.tallyRegions': '{country}의 지역',
+ 'assistant.card.atlas.tallyWishes': '버킷 리스트',
+ 'assistant.card.atlas.reached': '{date}에 달성',
+ 'assistant.card.atlas.duplicate': '이미 목록에 있음',
+ 'assistant.card.atlas.trips': '여행 {count}회',
+ 'assistant.card.atlas.places': '장소 {count}곳',
+ 'assistant.card.atlas.streak': '{count}년 연속',
+ 'assistant.tool.unmark_country_visited': '방문한 나라 빼기',
+ 'assistant.tool.mark_region_visited': '지역을 방문함으로 표시',
+ 'assistant.tool.unmark_region_visited': '방문한 지역 빼기',
+ 'assistant.tool.create_bucket_list_item': '버킷 리스트에 추가',
+ 'assistant.tool.update_bucket_list_item': '버킷 리스트 항목 변경',
+ 'assistant.tool.delete_bucket_list_item': '버킷 리스트에서 빼기',
+ 'assistant.step.atlas': '아틀라스를 읽는 중',
+ 'assistant.step.atlasCountry': '나라를 살펴보는 중',
+ 'assistant.step.regions': '지역을 읽는 중',
+ 'assistant.step.locateRegion': '지역을 찾는 중',
+ 'assistant.step.bucketList': '버킷 리스트를 읽는 중',
+ 'assistant.followUp.atlasWishes': '내 버킷 리스트에는 뭐가 있어?',
+ 'assistant.followUp.atlasLeast': '가장 적게 가 본 대륙은 어디야?',
+ 'assistant.followUp.atlasMark': '방문함으로 표시해 줘',
+ 'assistant.followUp.atlasWish': '버킷 리스트에 넣어 줘',
+ 'assistant.followUp.atlasPlanWish': '다음에는 어떤 소원을 계획할까?',
+ 'assistant.followUp.atlasAddWish': '소원 하나 더 추가해 줘',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/nl/assistantAtlas.ts b/shared/src/i18n/nl/assistantAtlas.ts
index 3d8e7daa8..a1c7c8676 100644
--- a/shared/src/i18n/nl/assistantAtlas.ts
+++ b/shared/src/i18n/nl/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Jouw atlas',
+ 'assistant.card.atlas.planned': '{count} gepland',
+ 'assistant.card.atlas.planned.one': '{count} gepland',
+ 'assistant.card.atlas.factFlown': 'Gevlogen',
+ 'assistant.card.atlas.open': 'Atlas openen',
+ 'assistant.card.atlas.nextTrip': 'Volgende reis: {title} over {count} dagen',
+ 'assistant.card.atlas.nextTrip.one': 'Volgende reis: {title} over {count} dag',
+ 'assistant.card.atlas.nextTripToday': 'Volgende reis: {title} begint vandaag',
+ 'assistant.card.atlas.status.visited': 'Bezocht',
+ 'assistant.card.atlas.status.planned': 'Gepland',
+ 'assistant.card.atlas.status.idea': 'Idee',
+ 'assistant.card.atlas.status.none': 'Niet op je kaart',
+ 'assistant.card.atlas.status.unknown': 'Je reizen beslissen',
+ 'assistant.card.atlas.dates': '{from} tot {to}',
+ 'assistant.card.atlas.fromRecordings': 'Uit je opnames',
+ 'assistant.card.atlas.tallyCountries': 'Bezochte landen',
+ 'assistant.card.atlas.tallyRegions': 'Regio’s in {country}',
+ 'assistant.card.atlas.tallyWishes': 'Bucketlist',
+ 'assistant.card.atlas.reached': 'Bereikt op {date}',
+ 'assistant.card.atlas.duplicate': 'Staat al op je lijst',
+ 'assistant.card.atlas.trips': '{count} reizen',
+ 'assistant.card.atlas.trips.one': '{count} reis',
+ 'assistant.card.atlas.places': '{count} plaatsen',
+ 'assistant.card.atlas.places.one': '{count} plaats',
+ 'assistant.card.atlas.streak': '{count} jaar op rij',
+ 'assistant.card.atlas.streak.one': '{count} jaar op rij',
+ 'assistant.tool.unmark_country_visited': 'Bezocht land verwijderen',
+ 'assistant.tool.mark_region_visited': 'Regio als bezocht markeren',
+ 'assistant.tool.unmark_region_visited': 'Bezochte regio verwijderen',
+ 'assistant.tool.create_bucket_list_item': 'Aan de bucketlist toevoegen',
+ 'assistant.tool.update_bucket_list_item': 'Wens op de bucketlist wijzigen',
+ 'assistant.tool.delete_bucket_list_item': 'Van de bucketlist verwijderen',
+ 'assistant.step.atlas': 'Je atlas lezen',
+ 'assistant.step.atlasCountry': 'Een land bekijken',
+ 'assistant.step.regions': 'Je regio’s lezen',
+ 'assistant.step.locateRegion': 'De regio zoeken',
+ 'assistant.step.bucketList': 'Je bucketlist lezen',
+ 'assistant.followUp.atlasWishes': 'Wat staat er op mijn bucketlist?',
+ 'assistant.followUp.atlasLeast': 'Van welk continent heb ik het minst gezien?',
+ 'assistant.followUp.atlasMark': 'Markeer het als bezocht',
+ 'assistant.followUp.atlasWish': 'Zet het op mijn bucketlist',
+ 'assistant.followUp.atlasPlanWish': 'Welke wens moet ik als volgende plannen?',
+ 'assistant.followUp.atlasAddWish': 'Voeg nog een wens toe',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/pl/assistantAtlas.ts b/shared/src/i18n/pl/assistantAtlas.ts
index 3d8e7daa8..3c63d7e87 100644
--- a/shared/src/i18n/pl/assistantAtlas.ts
+++ b/shared/src/i18n/pl/assistantAtlas.ts
@@ -1,4 +1,58 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Twój atlas',
+ 'assistant.card.atlas.planned': '{count} zaplanowanego',
+ 'assistant.card.atlas.planned.one': '{count} zaplanowany',
+ 'assistant.card.atlas.planned.few': '{count} zaplanowane',
+ 'assistant.card.atlas.planned.many': '{count} zaplanowanych',
+ 'assistant.card.atlas.factFlown': 'Przeleciane',
+ 'assistant.card.atlas.open': 'Otwórz Atlas',
+ 'assistant.card.atlas.nextTrip': 'Następna podróż: {title} za {count} dnia',
+ 'assistant.card.atlas.nextTrip.one': 'Następna podróż: {title} za {count} dzień',
+ 'assistant.card.atlas.nextTrip.few': 'Następna podróż: {title} za {count} dni',
+ 'assistant.card.atlas.nextTrip.many': 'Następna podróż: {title} za {count} dni',
+ 'assistant.card.atlas.nextTripToday': 'Następna podróż: {title} zaczyna się dziś',
+ 'assistant.card.atlas.status.visited': 'Odwiedzony',
+ 'assistant.card.atlas.status.planned': 'Zaplanowany',
+ 'assistant.card.atlas.status.idea': 'Pomysł',
+ 'assistant.card.atlas.status.none': 'Nie ma go na Twojej mapie',
+ 'assistant.card.atlas.status.unknown': 'Zdecydują Twoje podróże',
+ 'assistant.card.atlas.dates': 'od {from} do {to}',
+ 'assistant.card.atlas.fromRecordings': 'Z Twoich zapisów',
+ 'assistant.card.atlas.tallyCountries': 'Odwiedzone kraje',
+ 'assistant.card.atlas.tallyRegions': 'Regiony: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Lista marzeń',
+ 'assistant.card.atlas.reached': 'Osiągnięte {date}',
+ 'assistant.card.atlas.duplicate': 'Już jest na Twojej liście',
+ 'assistant.card.atlas.trips': '{count} podróży',
+ 'assistant.card.atlas.trips.one': '{count} podróż',
+ 'assistant.card.atlas.trips.few': '{count} podróże',
+ 'assistant.card.atlas.trips.many': '{count} podróży',
+ 'assistant.card.atlas.places': '{count} miejsca',
+ 'assistant.card.atlas.places.one': '{count} miejsce',
+ 'assistant.card.atlas.places.few': '{count} miejsca',
+ 'assistant.card.atlas.places.many': '{count} miejsc',
+ 'assistant.card.atlas.streak': '{count} roku z rzędu',
+ 'assistant.card.atlas.streak.one': '{count} rok z rzędu',
+ 'assistant.card.atlas.streak.few': '{count} lata z rzędu',
+ 'assistant.card.atlas.streak.many': '{count} lat z rzędu',
+ 'assistant.tool.unmark_country_visited': 'Usuń odwiedzony kraj',
+ 'assistant.tool.mark_region_visited': 'Oznacz region jako odwiedzony',
+ 'assistant.tool.unmark_region_visited': 'Usuń odwiedzony region',
+ 'assistant.tool.create_bucket_list_item': 'Dodaj do listy marzeń',
+ 'assistant.tool.update_bucket_list_item': 'Zmień marzenie z listy',
+ 'assistant.tool.delete_bucket_list_item': 'Usuń z listy marzeń',
+ 'assistant.step.atlas': 'Czytam Twój atlas',
+ 'assistant.step.atlasCountry': 'Sprawdzam kraj',
+ 'assistant.step.regions': 'Czytam Twoje regiony',
+ 'assistant.step.locateRegion': 'Szukam regionu',
+ 'assistant.step.bucketList': 'Czytam Twoją listę marzeń',
+ 'assistant.followUp.atlasWishes': 'Co jest na mojej liście marzeń?',
+ 'assistant.followUp.atlasLeast': 'Który kontynent znam najsłabiej?',
+ 'assistant.followUp.atlasMark': 'Oznacz go jako odwiedzony',
+ 'assistant.followUp.atlasWish': 'Dodaj go do mojej listy marzeń',
+ 'assistant.followUp.atlasPlanWish': 'Które marzenie zaplanować jako następne?',
+ 'assistant.followUp.atlasAddWish': 'Dodaj kolejne marzenie',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/ru/assistantAtlas.ts b/shared/src/i18n/ru/assistantAtlas.ts
index 3d8e7daa8..3e1441e52 100644
--- a/shared/src/i18n/ru/assistantAtlas.ts
+++ b/shared/src/i18n/ru/assistantAtlas.ts
@@ -1,4 +1,58 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Ваш атлас',
+ 'assistant.card.atlas.planned': '{count} запланированной',
+ 'assistant.card.atlas.planned.one': '{count} запланирована',
+ 'assistant.card.atlas.planned.few': '{count} запланированы',
+ 'assistant.card.atlas.planned.many': '{count} запланировано',
+ 'assistant.card.atlas.factFlown': 'Налёт',
+ 'assistant.card.atlas.open': 'Открыть Атлас',
+ 'assistant.card.atlas.nextTrip': 'Следующая поездка: {title} через {count} дня',
+ 'assistant.card.atlas.nextTrip.one': 'Следующая поездка: {title} через {count} день',
+ 'assistant.card.atlas.nextTrip.few': 'Следующая поездка: {title} через {count} дня',
+ 'assistant.card.atlas.nextTrip.many': 'Следующая поездка: {title} через {count} дней',
+ 'assistant.card.atlas.nextTripToday': 'Следующая поездка: {title} начинается сегодня',
+ 'assistant.card.atlas.status.visited': 'Посещена',
+ 'assistant.card.atlas.status.planned': 'Запланирована',
+ 'assistant.card.atlas.status.idea': 'Идея',
+ 'assistant.card.atlas.status.none': 'Нет на вашей карте',
+ 'assistant.card.atlas.status.unknown': 'Решат ваши поездки',
+ 'assistant.card.atlas.dates': 'с {from} по {to}',
+ 'assistant.card.atlas.fromRecordings': 'По вашим записям',
+ 'assistant.card.atlas.tallyCountries': 'Посещённые страны',
+ 'assistant.card.atlas.tallyRegions': 'Регионы: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Список желаний',
+ 'assistant.card.atlas.reached': 'Достигнуто {date}',
+ 'assistant.card.atlas.duplicate': 'Уже в вашем списке',
+ 'assistant.card.atlas.trips': '{count} поездки',
+ 'assistant.card.atlas.trips.one': '{count} поездка',
+ 'assistant.card.atlas.trips.few': '{count} поездки',
+ 'assistant.card.atlas.trips.many': '{count} поездок',
+ 'assistant.card.atlas.places': '{count} места',
+ 'assistant.card.atlas.places.one': '{count} место',
+ 'assistant.card.atlas.places.few': '{count} места',
+ 'assistant.card.atlas.places.many': '{count} мест',
+ 'assistant.card.atlas.streak': '{count} года подряд',
+ 'assistant.card.atlas.streak.one': '{count} год подряд',
+ 'assistant.card.atlas.streak.few': '{count} года подряд',
+ 'assistant.card.atlas.streak.many': '{count} лет подряд',
+ 'assistant.tool.unmark_country_visited': 'Убрать посещённую страну',
+ 'assistant.tool.mark_region_visited': 'Отметить регион как посещённый',
+ 'assistant.tool.unmark_region_visited': 'Убрать посещённый регион',
+ 'assistant.tool.create_bucket_list_item': 'Добавить в список желаний',
+ 'assistant.tool.update_bucket_list_item': 'Изменить желание в списке',
+ 'assistant.tool.delete_bucket_list_item': 'Убрать из списка желаний',
+ 'assistant.step.atlas': 'Читаю ваш атлас',
+ 'assistant.step.atlasCountry': 'Смотрю страну',
+ 'assistant.step.regions': 'Читаю ваши регионы',
+ 'assistant.step.locateRegion': 'Ищу регион',
+ 'assistant.step.bucketList': 'Читаю ваш список желаний',
+ 'assistant.followUp.atlasWishes': 'Что в моём списке желаний?',
+ 'assistant.followUp.atlasLeast': 'Какой континент я видел меньше всего?',
+ 'assistant.followUp.atlasMark': 'Отметь её как посещённую',
+ 'assistant.followUp.atlasWish': 'Добавь её в мой список желаний',
+ 'assistant.followUp.atlasPlanWish': 'Какое желание мне спланировать следующим?',
+ 'assistant.followUp.atlasAddWish': 'Добавь ещё одно желание',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/sk/assistantAtlas.ts b/shared/src/i18n/sk/assistantAtlas.ts
index 3d8e7daa8..340907efb 100644
--- a/shared/src/i18n/sk/assistantAtlas.ts
+++ b/shared/src/i18n/sk/assistantAtlas.ts
@@ -1,4 +1,53 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Tvoj atlas',
+ 'assistant.card.atlas.planned': '{count} plánovaných',
+ 'assistant.card.atlas.planned.one': '{count} plánovaná',
+ 'assistant.card.atlas.planned.few': '{count} plánované',
+ 'assistant.card.atlas.factFlown': 'Nalietané',
+ 'assistant.card.atlas.open': 'Otvoriť Atlas',
+ 'assistant.card.atlas.nextTrip': 'Ďalšia cesta: {title} o {count} dní',
+ 'assistant.card.atlas.nextTrip.one': 'Ďalšia cesta: {title} o {count} deň',
+ 'assistant.card.atlas.nextTrip.few': 'Ďalšia cesta: {title} o {count} dni',
+ 'assistant.card.atlas.nextTripToday': 'Ďalšia cesta: {title} začína dnes',
+ 'assistant.card.atlas.status.visited': 'Navštívené',
+ 'assistant.card.atlas.status.planned': 'Plánované',
+ 'assistant.card.atlas.status.idea': 'Nápad',
+ 'assistant.card.atlas.status.none': 'Nie je na tvojej mape',
+ 'assistant.card.atlas.status.unknown': 'Rozhodnú tvoje cesty',
+ 'assistant.card.atlas.dates': '{from} až {to}',
+ 'assistant.card.atlas.fromRecordings': 'Z tvojich záznamov',
+ 'assistant.card.atlas.tallyCountries': 'Navštívené krajiny',
+ 'assistant.card.atlas.tallyRegions': 'Regióny: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Zoznam prianí',
+ 'assistant.card.atlas.reached': 'Splnené {date}',
+ 'assistant.card.atlas.duplicate': 'Už je na tvojom zozname',
+ 'assistant.card.atlas.trips': '{count} ciest',
+ 'assistant.card.atlas.trips.one': '{count} cesta',
+ 'assistant.card.atlas.trips.few': '{count} cesty',
+ 'assistant.card.atlas.places': '{count} miest',
+ 'assistant.card.atlas.places.one': '{count} miesto',
+ 'assistant.card.atlas.places.few': '{count} miesta',
+ 'assistant.card.atlas.streak': '{count} rokov po sebe',
+ 'assistant.card.atlas.streak.one': '{count} rok po sebe',
+ 'assistant.card.atlas.streak.few': '{count} roky po sebe',
+ 'assistant.tool.unmark_country_visited': 'Odobrať navštívenú krajinu',
+ 'assistant.tool.mark_region_visited': 'Označiť región ako navštívený',
+ 'assistant.tool.unmark_region_visited': 'Odobrať navštívený región',
+ 'assistant.tool.create_bucket_list_item': 'Pridať na zoznam prianí',
+ 'assistant.tool.update_bucket_list_item': 'Zmeniť prianie na zozname',
+ 'assistant.tool.delete_bucket_list_item': 'Odobrať zo zoznamu prianí',
+ 'assistant.step.atlas': 'Čítam tvoj atlas',
+ 'assistant.step.atlasCountry': 'Pozerám krajinu',
+ 'assistant.step.regions': 'Čítam tvoje regióny',
+ 'assistant.step.locateRegion': 'Hľadám región',
+ 'assistant.step.bucketList': 'Čítam tvoj zoznam prianí',
+ 'assistant.followUp.atlasWishes': 'Čo mám na zozname prianí?',
+ 'assistant.followUp.atlasLeast': 'Ktorý kontinent poznám najmenej?',
+ 'assistant.followUp.atlasMark': 'Označ ju ako navštívenú',
+ 'assistant.followUp.atlasWish': 'Daj ju na môj zoznam prianí',
+ 'assistant.followUp.atlasPlanWish': 'Ktoré prianie mám naplánovať ako ďalšie?',
+ 'assistant.followUp.atlasAddWish': 'Pridaj ďalšie prianie',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/sv/assistantAtlas.ts b/shared/src/i18n/sv/assistantAtlas.ts
index 3d8e7daa8..387725760 100644
--- a/shared/src/i18n/sv/assistantAtlas.ts
+++ b/shared/src/i18n/sv/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Din atlas',
+ 'assistant.card.atlas.planned': '{count} planerade',
+ 'assistant.card.atlas.planned.one': '{count} planerad',
+ 'assistant.card.atlas.factFlown': 'Flugit',
+ 'assistant.card.atlas.open': 'Öppna Atlas',
+ 'assistant.card.atlas.nextTrip': 'Nästa resa: {title} om {count} dagar',
+ 'assistant.card.atlas.nextTrip.one': 'Nästa resa: {title} om {count} dag',
+ 'assistant.card.atlas.nextTripToday': 'Nästa resa: {title} börjar i dag',
+ 'assistant.card.atlas.status.visited': 'Besökt',
+ 'assistant.card.atlas.status.planned': 'Planerat',
+ 'assistant.card.atlas.status.idea': 'Idé',
+ 'assistant.card.atlas.status.none': 'Inte på din karta',
+ 'assistant.card.atlas.status.unknown': 'Dina resor avgör',
+ 'assistant.card.atlas.dates': '{from} till {to}',
+ 'assistant.card.atlas.fromRecordings': 'Från dina inspelningar',
+ 'assistant.card.atlas.tallyCountries': 'Besökta länder',
+ 'assistant.card.atlas.tallyRegions': 'Regioner i {country}',
+ 'assistant.card.atlas.tallyWishes': 'Bucketlista',
+ 'assistant.card.atlas.reached': 'Nådd {date}',
+ 'assistant.card.atlas.duplicate': 'Redan på din lista',
+ 'assistant.card.atlas.trips': '{count} resor',
+ 'assistant.card.atlas.trips.one': '{count} resa',
+ 'assistant.card.atlas.places': '{count} platser',
+ 'assistant.card.atlas.places.one': '{count} plats',
+ 'assistant.card.atlas.streak': '{count} år i rad',
+ 'assistant.card.atlas.streak.one': '{count} år i rad',
+ 'assistant.tool.unmark_country_visited': 'Ta bort ett besökt land',
+ 'assistant.tool.mark_region_visited': 'Markera regionen som besökt',
+ 'assistant.tool.unmark_region_visited': 'Ta bort en besökt region',
+ 'assistant.tool.create_bucket_list_item': 'Lägg till på bucketlistan',
+ 'assistant.tool.update_bucket_list_item': 'Ändra en önskan på bucketlistan',
+ 'assistant.tool.delete_bucket_list_item': 'Ta bort från bucketlistan',
+ 'assistant.step.atlas': 'Läser din atlas',
+ 'assistant.step.atlasCountry': 'Tittar på ett land',
+ 'assistant.step.regions': 'Läser dina regioner',
+ 'assistant.step.locateRegion': 'Letar upp regionen',
+ 'assistant.step.bucketList': 'Läser din bucketlista',
+ 'assistant.followUp.atlasWishes': 'Vad står på min bucketlista?',
+ 'assistant.followUp.atlasLeast': 'Vilken kontinent har jag sett minst av?',
+ 'assistant.followUp.atlasMark': 'Markera det som besökt',
+ 'assistant.followUp.atlasWish': 'Sätt upp det på min bucketlista',
+ 'assistant.followUp.atlasPlanWish': 'Vilken önskan borde jag planera härnäst?',
+ 'assistant.followUp.atlasAddWish': 'Lägg till en önskan till',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/th/assistantAtlas.ts b/shared/src/i18n/th/assistantAtlas.ts
index 3d8e7daa8..6ffb29adf 100644
--- a/shared/src/i18n/th/assistantAtlas.ts
+++ b/shared/src/i18n/th/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'แผนที่โลกของคุณ',
+ 'assistant.card.atlas.planned': 'วางแผนไว้ {count}',
+ 'assistant.card.atlas.factFlown': 'ระยะบิน',
+ 'assistant.card.atlas.open': 'เปิดแผนที่โลก',
+ 'assistant.card.atlas.nextTrip': 'ทริปถัดไป: {title} อีก {count} วัน',
+ 'assistant.card.atlas.nextTripToday': 'ทริปถัดไป: {title} เริ่มวันนี้',
+ 'assistant.card.atlas.status.visited': 'เคยไปแล้ว',
+ 'assistant.card.atlas.status.planned': 'วางแผนแล้ว',
+ 'assistant.card.atlas.status.idea': 'ไอเดีย',
+ 'assistant.card.atlas.status.none': 'ไม่อยู่บนแผนที่ของคุณ',
+ 'assistant.card.atlas.status.unknown': 'ขึ้นอยู่กับทริปของคุณ',
+ 'assistant.card.atlas.dates': '{from} ถึง {to}',
+ 'assistant.card.atlas.fromRecordings': 'จากบันทึกของคุณ',
+ 'assistant.card.atlas.tallyCountries': 'ประเทศที่เคยไป',
+ 'assistant.card.atlas.tallyRegions': 'ภูมิภาคใน {country}',
+ 'assistant.card.atlas.tallyWishes': 'สถานที่อยากไป',
+ 'assistant.card.atlas.reached': 'สำเร็จเมื่อ {date}',
+ 'assistant.card.atlas.duplicate': 'มีอยู่ในรายการแล้ว',
+ 'assistant.card.atlas.trips': '{count} ทริป',
+ 'assistant.card.atlas.places': '{count} สถานที่',
+ 'assistant.card.atlas.streak': '{count} ปีติดต่อกัน',
+ 'assistant.tool.unmark_country_visited': 'เอาประเทศที่เคยไปออก',
+ 'assistant.tool.mark_region_visited': 'ทำเครื่องหมายว่าเคยไปภูมิภาคนี้',
+ 'assistant.tool.unmark_region_visited': 'เอาภูมิภาคที่เคยไปออก',
+ 'assistant.tool.create_bucket_list_item': 'เพิ่มในรายการอยากไป',
+ 'assistant.tool.update_bucket_list_item': 'แก้ไขรายการอยากไป',
+ 'assistant.tool.delete_bucket_list_item': 'เอาออกจากรายการอยากไป',
+ 'assistant.step.atlas': 'กำลังอ่านแผนที่โลกของคุณ',
+ 'assistant.step.atlasCountry': 'กำลังดูประเทศ',
+ 'assistant.step.regions': 'กำลังอ่านภูมิภาคของคุณ',
+ 'assistant.step.locateRegion': 'กำลังหาภูมิภาค',
+ 'assistant.step.bucketList': 'กำลังอ่านรายการอยากไป',
+ 'assistant.followUp.atlasWishes': 'มีอะไรอยู่ในรายการอยากไปของฉันบ้าง?',
+ 'assistant.followUp.atlasLeast': 'ทวีปไหนที่ฉันไปน้อยที่สุด?',
+ 'assistant.followUp.atlasMark': 'ทำเครื่องหมายว่าเคยไปแล้ว',
+ 'assistant.followUp.atlasWish': 'เพิ่มลงในรายการอยากไปของฉัน',
+ 'assistant.followUp.atlasPlanWish': 'ควรวางแผนไปที่ไหนต่อดี?',
+ 'assistant.followUp.atlasAddWish': 'เพิ่มที่อยากไปอีกแห่ง',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/tr/assistantAtlas.ts b/shared/src/i18n/tr/assistantAtlas.ts
index 3d8e7daa8..32b31b1c5 100644
--- a/shared/src/i18n/tr/assistantAtlas.ts
+++ b/shared/src/i18n/tr/assistantAtlas.ts
@@ -1,4 +1,48 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Atlasın',
+ 'assistant.card.atlas.planned': '{count} planlandı',
+ 'assistant.card.atlas.planned.one': '{count} planlandı',
+ 'assistant.card.atlas.factFlown': 'Uçuş',
+ 'assistant.card.atlas.open': 'Atlas’ı aç',
+ 'assistant.card.atlas.nextTrip': 'Sonraki gezi: {title}, {count} gün sonra',
+ 'assistant.card.atlas.nextTrip.one': 'Sonraki gezi: {title}, {count} gün sonra',
+ 'assistant.card.atlas.nextTripToday': 'Sonraki gezi: {title} bugün başlıyor',
+ 'assistant.card.atlas.status.visited': 'Ziyaret edildi',
+ 'assistant.card.atlas.status.planned': 'Planlandı',
+ 'assistant.card.atlas.status.idea': 'Fikir',
+ 'assistant.card.atlas.status.none': 'Haritanda yok',
+ 'assistant.card.atlas.status.unknown': 'Gezilerin belirler',
+ 'assistant.card.atlas.dates': '{from} ile {to} arası',
+ 'assistant.card.atlas.fromRecordings': 'Kayıtlarından',
+ 'assistant.card.atlas.tallyCountries': 'Ziyaret edilen ülkeler',
+ 'assistant.card.atlas.tallyRegions': '{country} bölgeleri',
+ 'assistant.card.atlas.tallyWishes': 'Dilek listesi',
+ 'assistant.card.atlas.reached': '{date} tarihinde ulaşıldı',
+ 'assistant.card.atlas.duplicate': 'Zaten listende',
+ 'assistant.card.atlas.trips': '{count} gezi',
+ 'assistant.card.atlas.trips.one': '{count} gezi',
+ 'assistant.card.atlas.places': '{count} yer',
+ 'assistant.card.atlas.places.one': '{count} yer',
+ 'assistant.card.atlas.streak': '{count} yıl üst üste',
+ 'assistant.card.atlas.streak.one': '{count} yıl üst üste',
+ 'assistant.tool.unmark_country_visited': 'Ziyaret edilen bir ülkeyi kaldır',
+ 'assistant.tool.mark_region_visited': 'Bölgeyi ziyaret edildi olarak işaretle',
+ 'assistant.tool.unmark_region_visited': 'Ziyaret edilen bir bölgeyi kaldır',
+ 'assistant.tool.create_bucket_list_item': 'Dilek listesine ekle',
+ 'assistant.tool.update_bucket_list_item': 'Dilek listesindeki bir dileği değiştir',
+ 'assistant.tool.delete_bucket_list_item': 'Dilek listesinden kaldır',
+ 'assistant.step.atlas': 'Atlasın okunuyor',
+ 'assistant.step.atlasCountry': 'Bir ülkeye bakılıyor',
+ 'assistant.step.regions': 'Bölgelerin okunuyor',
+ 'assistant.step.locateRegion': 'Bölge bulunuyor',
+ 'assistant.step.bucketList': 'Dilek listen okunuyor',
+ 'assistant.followUp.atlasWishes': 'Dilek listemde neler var?',
+ 'assistant.followUp.atlasLeast': 'En az hangi kıtayı gördüm?',
+ 'assistant.followUp.atlasMark': 'Ziyaret edildi olarak işaretle',
+ 'assistant.followUp.atlasWish': 'Dilek listeme ekle',
+ 'assistant.followUp.atlasPlanWish': 'Sırada hangi dileği planlamalıyım?',
+ 'assistant.followUp.atlasAddWish': 'Bir dilek daha ekle',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/uk/assistantAtlas.ts b/shared/src/i18n/uk/assistantAtlas.ts
index 3d8e7daa8..97bcf62a1 100644
--- a/shared/src/i18n/uk/assistantAtlas.ts
+++ b/shared/src/i18n/uk/assistantAtlas.ts
@@ -1,4 +1,58 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Ваш атлас',
+ 'assistant.card.atlas.planned': '{count} запланованої',
+ 'assistant.card.atlas.planned.one': '{count} запланована',
+ 'assistant.card.atlas.planned.few': '{count} заплановані',
+ 'assistant.card.atlas.planned.many': '{count} запланованих',
+ 'assistant.card.atlas.factFlown': 'Наліт',
+ 'assistant.card.atlas.open': 'Відкрити Атлас',
+ 'assistant.card.atlas.nextTrip': 'Наступна подорож: {title} через {count} дня',
+ 'assistant.card.atlas.nextTrip.one': 'Наступна подорож: {title} через {count} день',
+ 'assistant.card.atlas.nextTrip.few': 'Наступна подорож: {title} через {count} дні',
+ 'assistant.card.atlas.nextTrip.many': 'Наступна подорож: {title} через {count} днів',
+ 'assistant.card.atlas.nextTripToday': 'Наступна подорож: {title} починається сьогодні',
+ 'assistant.card.atlas.status.visited': 'Відвідано',
+ 'assistant.card.atlas.status.planned': 'Заплановано',
+ 'assistant.card.atlas.status.idea': 'Ідея',
+ 'assistant.card.atlas.status.none': 'Немає на вашій мапі',
+ 'assistant.card.atlas.status.unknown': 'Вирішать ваші подорожі',
+ 'assistant.card.atlas.dates': 'з {from} по {to}',
+ 'assistant.card.atlas.fromRecordings': 'За вашими записами',
+ 'assistant.card.atlas.tallyCountries': 'Відвідані країни',
+ 'assistant.card.atlas.tallyRegions': 'Регіони: {country}',
+ 'assistant.card.atlas.tallyWishes': 'Список бажань',
+ 'assistant.card.atlas.reached': 'Досягнуто {date}',
+ 'assistant.card.atlas.duplicate': 'Уже у вашому списку',
+ 'assistant.card.atlas.trips': '{count} поїздки',
+ 'assistant.card.atlas.trips.one': '{count} поїздка',
+ 'assistant.card.atlas.trips.few': '{count} поїздки',
+ 'assistant.card.atlas.trips.many': '{count} поїздок',
+ 'assistant.card.atlas.places': '{count} місця',
+ 'assistant.card.atlas.places.one': '{count} місце',
+ 'assistant.card.atlas.places.few': '{count} місця',
+ 'assistant.card.atlas.places.many': '{count} місць',
+ 'assistant.card.atlas.streak': '{count} року підряд',
+ 'assistant.card.atlas.streak.one': '{count} рік підряд',
+ 'assistant.card.atlas.streak.few': '{count} роки підряд',
+ 'assistant.card.atlas.streak.many': '{count} років підряд',
+ 'assistant.tool.unmark_country_visited': 'Прибрати відвідану країну',
+ 'assistant.tool.mark_region_visited': 'Позначити регіон як відвіданий',
+ 'assistant.tool.unmark_region_visited': 'Прибрати відвіданий регіон',
+ 'assistant.tool.create_bucket_list_item': 'Додати до списку бажань',
+ 'assistant.tool.update_bucket_list_item': 'Змінити бажання у списку',
+ 'assistant.tool.delete_bucket_list_item': 'Прибрати зі списку бажань',
+ 'assistant.step.atlas': 'Читаю ваш атлас',
+ 'assistant.step.atlasCountry': 'Дивлюся країну',
+ 'assistant.step.regions': 'Читаю ваші регіони',
+ 'assistant.step.locateRegion': 'Шукаю регіон',
+ 'assistant.step.bucketList': 'Читаю ваш список бажань',
+ 'assistant.followUp.atlasWishes': 'Що в моєму списку бажань?',
+ 'assistant.followUp.atlasLeast': 'Який континент я бачив найменше?',
+ 'assistant.followUp.atlasMark': 'Познач її як відвідану',
+ 'assistant.followUp.atlasWish': 'Додай її до мого списку бажань',
+ 'assistant.followUp.atlasPlanWish': 'Яке бажання мені спланувати наступним?',
+ 'assistant.followUp.atlasAddWish': 'Додай ще одне бажання',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/vi/assistantAtlas.ts b/shared/src/i18n/vi/assistantAtlas.ts
index 3d8e7daa8..aff90c3af 100644
--- a/shared/src/i18n/vi/assistantAtlas.ts
+++ b/shared/src/i18n/vi/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': 'Atlas của bạn',
+ 'assistant.card.atlas.planned': '{count} đã lên kế hoạch',
+ 'assistant.card.atlas.factFlown': 'Đã bay',
+ 'assistant.card.atlas.open': 'Mở Atlas',
+ 'assistant.card.atlas.nextTrip': 'Chuyến đi tới: {title} sau {count} ngày',
+ 'assistant.card.atlas.nextTripToday': 'Chuyến đi tới: {title} bắt đầu hôm nay',
+ 'assistant.card.atlas.status.visited': 'Đã đến',
+ 'assistant.card.atlas.status.planned': 'Đã lên kế hoạch',
+ 'assistant.card.atlas.status.idea': 'Ý tưởng',
+ 'assistant.card.atlas.status.none': 'Không có trên bản đồ của bạn',
+ 'assistant.card.atlas.status.unknown': 'Do các chuyến đi của bạn quyết định',
+ 'assistant.card.atlas.dates': '{from} đến {to}',
+ 'assistant.card.atlas.fromRecordings': 'Từ bản ghi của bạn',
+ 'assistant.card.atlas.tallyCountries': 'Các nước đã đến',
+ 'assistant.card.atlas.tallyRegions': 'Các vùng ở {country}',
+ 'assistant.card.atlas.tallyWishes': 'Danh sách mong muốn',
+ 'assistant.card.atlas.reached': 'Đạt được ngày {date}',
+ 'assistant.card.atlas.duplicate': 'Đã có trong danh sách của bạn',
+ 'assistant.card.atlas.trips': '{count} chuyến đi',
+ 'assistant.card.atlas.places': '{count} địa điểm',
+ 'assistant.card.atlas.streak': '{count} năm liên tiếp',
+ 'assistant.tool.unmark_country_visited': 'Bỏ một nước đã đến',
+ 'assistant.tool.mark_region_visited': 'Đánh dấu vùng đã đến',
+ 'assistant.tool.unmark_region_visited': 'Bỏ một vùng đã đến',
+ 'assistant.tool.create_bucket_list_item': 'Thêm vào danh sách mong muốn',
+ 'assistant.tool.update_bucket_list_item': 'Sửa một mong muốn trong danh sách',
+ 'assistant.tool.delete_bucket_list_item': 'Bỏ khỏi danh sách mong muốn',
+ 'assistant.step.atlas': 'Đang đọc Atlas của bạn',
+ 'assistant.step.atlasCountry': 'Đang xem một quốc gia',
+ 'assistant.step.regions': 'Đang đọc các vùng của bạn',
+ 'assistant.step.locateRegion': 'Đang tìm vùng',
+ 'assistant.step.bucketList': 'Đang đọc danh sách mong muốn',
+ 'assistant.followUp.atlasWishes': 'Danh sách mong muốn của tôi có gì?',
+ 'assistant.followUp.atlasLeast': 'Tôi đi ít nhất châu lục nào?',
+ 'assistant.followUp.atlasMark': 'Đánh dấu là đã đến',
+ 'assistant.followUp.atlasWish': 'Thêm vào danh sách mong muốn của tôi',
+ 'assistant.followUp.atlasPlanWish': 'Tôi nên lên kế hoạch cho mong muốn nào tiếp theo?',
+ 'assistant.followUp.atlasAddWish': 'Thêm một mong muốn nữa',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/zh-TW/assistantAtlas.ts b/shared/src/i18n/zh-TW/assistantAtlas.ts
index 3d8e7daa8..722123ed7 100644
--- a/shared/src/i18n/zh-TW/assistantAtlas.ts
+++ b/shared/src/i18n/zh-TW/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': '你的地圖集',
+ 'assistant.card.atlas.planned': '{count} 個計劃中',
+ 'assistant.card.atlas.factFlown': '飛行里程',
+ 'assistant.card.atlas.open': '開啟地圖集',
+ 'assistant.card.atlas.nextTrip': '下一趟旅行:{title},還有 {count} 天',
+ 'assistant.card.atlas.nextTripToday': '下一趟旅行:{title} 今天開始',
+ 'assistant.card.atlas.status.visited': '已去過',
+ 'assistant.card.atlas.status.planned': '計劃中',
+ 'assistant.card.atlas.status.idea': '想法',
+ 'assistant.card.atlas.status.none': '不在你的地圖上',
+ 'assistant.card.atlas.status.unknown': '取決於你的旅行',
+ 'assistant.card.atlas.dates': '{from} 至 {to}',
+ 'assistant.card.atlas.fromRecordings': '來自你的記錄',
+ 'assistant.card.atlas.tallyCountries': '去過的國家',
+ 'assistant.card.atlas.tallyRegions': '{country} 的地區',
+ 'assistant.card.atlas.tallyWishes': '心願單',
+ 'assistant.card.atlas.reached': '{date} 實現',
+ 'assistant.card.atlas.duplicate': '已在你的清單上',
+ 'assistant.card.atlas.trips': '{count} 次旅行',
+ 'assistant.card.atlas.places': '{count} 個地點',
+ 'assistant.card.atlas.streak': '連續 {count} 年',
+ 'assistant.tool.unmark_country_visited': '移除去過的國家',
+ 'assistant.tool.mark_region_visited': '標記地區為已去過',
+ 'assistant.tool.unmark_region_visited': '移除去過的地區',
+ 'assistant.tool.create_bucket_list_item': '加入心願單',
+ 'assistant.tool.update_bucket_list_item': '修改心願單中的心願',
+ 'assistant.tool.delete_bucket_list_item': '從心願單移除',
+ 'assistant.step.atlas': '正在讀取你的地圖集',
+ 'assistant.step.atlasCountry': '正在查看一個國家',
+ 'assistant.step.regions': '正在讀取你的地區',
+ 'assistant.step.locateRegion': '正在尋找地區',
+ 'assistant.step.bucketList': '正在讀取你的心願單',
+ 'assistant.followUp.atlasWishes': '我的心願單上有什麼?',
+ 'assistant.followUp.atlasLeast': '我去得最少的是哪個洲?',
+ 'assistant.followUp.atlasMark': '標記為已去過',
+ 'assistant.followUp.atlasWish': '加入我的心願單',
+ 'assistant.followUp.atlasPlanWish': '接下來我該規劃哪個心願?',
+ 'assistant.followUp.atlasAddWish': '再新增一個心願',
+};
export default assistantAtlas;
diff --git a/shared/src/i18n/zh/assistantAtlas.ts b/shared/src/i18n/zh/assistantAtlas.ts
index 3d8e7daa8..4d4ca7efb 100644
--- a/shared/src/i18n/zh/assistantAtlas.ts
+++ b/shared/src/i18n/zh/assistantAtlas.ts
@@ -1,4 +1,43 @@
import type { TranslationStrings } from '../types';
-const assistantAtlas: TranslationStrings = {};
+const assistantAtlas: TranslationStrings = {
+ 'assistant.card.atlas.passportTitle': '你的地图集',
+ 'assistant.card.atlas.planned': '{count} 个计划中',
+ 'assistant.card.atlas.factFlown': '飞行里程',
+ 'assistant.card.atlas.open': '打开地图集',
+ 'assistant.card.atlas.nextTrip': '下一次旅行:{title},还有 {count} 天',
+ 'assistant.card.atlas.nextTripToday': '下一次旅行:{title} 今天开始',
+ 'assistant.card.atlas.status.visited': '已去过',
+ 'assistant.card.atlas.status.planned': '计划中',
+ 'assistant.card.atlas.status.idea': '想法',
+ 'assistant.card.atlas.status.none': '不在你的地图上',
+ 'assistant.card.atlas.status.unknown': '取决于你的旅行',
+ 'assistant.card.atlas.dates': '{from} 至 {to}',
+ 'assistant.card.atlas.fromRecordings': '来自你的记录',
+ 'assistant.card.atlas.tallyCountries': '去过的国家',
+ 'assistant.card.atlas.tallyRegions': '{country} 的地区',
+ 'assistant.card.atlas.tallyWishes': '心愿单',
+ 'assistant.card.atlas.reached': '{date} 实现',
+ 'assistant.card.atlas.duplicate': '已在你的清单上',
+ 'assistant.card.atlas.trips': '{count} 次旅行',
+ 'assistant.card.atlas.places': '{count} 个地点',
+ 'assistant.card.atlas.streak': '连续 {count} 年',
+ 'assistant.tool.unmark_country_visited': '移除去过的国家',
+ 'assistant.tool.mark_region_visited': '标记地区为已去过',
+ 'assistant.tool.unmark_region_visited': '移除去过的地区',
+ 'assistant.tool.create_bucket_list_item': '加入心愿单',
+ 'assistant.tool.update_bucket_list_item': '修改心愿单中的心愿',
+ 'assistant.tool.delete_bucket_list_item': '从心愿单移除',
+ 'assistant.step.atlas': '正在读取你的地图集',
+ 'assistant.step.atlasCountry': '正在查看一个国家',
+ 'assistant.step.regions': '正在读取你的地区',
+ 'assistant.step.locateRegion': '正在查找地区',
+ 'assistant.step.bucketList': '正在读取你的心愿单',
+ 'assistant.followUp.atlasWishes': '我的心愿单上有什么?',
+ 'assistant.followUp.atlasLeast': '我去得最少的是哪个大洲?',
+ 'assistant.followUp.atlasMark': '标记为已去过',
+ 'assistant.followUp.atlasWish': '加入我的心愿单',
+ 'assistant.followUp.atlasPlanWish': '接下来我该计划哪个心愿?',
+ 'assistant.followUp.atlasAddWish': '再添加一个心愿',
+};
export default assistantAtlas;