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

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