mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(assistant): passport, country and bucket list cards
This commit is contained in:
@@ -0,0 +1,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) };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,4 +1,101 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import type { DomainCards, ToolCards } from '../card-types';
|
||||
import { ATLAS_ANSWERS } from './atlas-answers';
|
||||
import { ATLAS_APPLIED, DELETE_ADDED_WISH } from './atlas-applied';
|
||||
import {
|
||||
ATLAS_CHANGES,
|
||||
ATLAS_DESCRIBERS,
|
||||
loadAtlas,
|
||||
loadAtlasRegions,
|
||||
loadBucket,
|
||||
type AtlasLoaded,
|
||||
} from './atlas-changes';
|
||||
|
||||
/** The cards of the atlas: countries, regions and the bucket list. None yet: this package fills them in. */
|
||||
export const ATLAS_CARDS: DomainCards = { id: 'atlas', tools: {} };
|
||||
/**
|
||||
* The atlas in the assistant chat: the passport of the user's travels, one
|
||||
* country with its trips and places, the bucket list as wish tiles, and the
|
||||
* changes to them drawn with their flag and the counters they move. Atlas
|
||||
* rows belong to the user, not to the trip, so nothing here lands on the
|
||||
* trip's map; a receipt opens the Atlas instead. Only an added wish can be
|
||||
* taken back: an unmark hides a country and may take regions along, which no
|
||||
* mark restores.
|
||||
*/
|
||||
|
||||
const ATLAS_WRITE = { entity: 'atlas' } as const;
|
||||
|
||||
const markCountry: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadAtlas,
|
||||
describe: ATLAS_DESCRIBERS.mark_country_visited,
|
||||
change: ATLAS_CHANGES.mark_country_visited,
|
||||
applied: ATLAS_APPLIED.mark_country_visited,
|
||||
};
|
||||
|
||||
const unmarkCountry: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadAtlas,
|
||||
describe: ATLAS_DESCRIBERS.unmark_country_visited,
|
||||
change: ATLAS_CHANGES.unmark_country_visited,
|
||||
applied: ATLAS_APPLIED.unmark_country_visited,
|
||||
};
|
||||
|
||||
const markRegion: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadAtlasRegions,
|
||||
describe: ATLAS_DESCRIBERS.mark_region_visited,
|
||||
change: ATLAS_CHANGES.mark_region_visited,
|
||||
applied: ATLAS_APPLIED.mark_region_visited,
|
||||
};
|
||||
|
||||
const unmarkRegion: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadAtlasRegions,
|
||||
describe: ATLAS_DESCRIBERS.unmark_region_visited,
|
||||
change: ATLAS_CHANGES.unmark_region_visited,
|
||||
applied: ATLAS_APPLIED.unmark_region_visited,
|
||||
};
|
||||
|
||||
const createWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericCreate' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.create_bucket_list_item,
|
||||
change: ATLAS_CHANGES.create_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.create_bucket_list_item,
|
||||
undo: DELETE_ADDED_WISH,
|
||||
};
|
||||
|
||||
const updateWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericUpdate' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.update_bucket_list_item,
|
||||
change: ATLAS_CHANGES.update_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.update_bucket_list_item,
|
||||
};
|
||||
|
||||
const deleteWish: ToolCards<AtlasLoaded> = {
|
||||
write: { ...ATLAS_WRITE, verbKey: 'genericRemove' },
|
||||
load: loadBucket,
|
||||
describe: ATLAS_DESCRIBERS.delete_bucket_list_item,
|
||||
change: ATLAS_CHANGES.delete_bucket_list_item,
|
||||
applied: ATLAS_APPLIED.delete_bucket_list_item,
|
||||
};
|
||||
|
||||
export const ATLAS_CARDS: DomainCards = {
|
||||
id: 'atlas',
|
||||
tools: {
|
||||
get_atlas_stats: { answer: ATLAS_ANSWERS.get_atlas_stats },
|
||||
get_country_atlas_places: { answer: ATLAS_ANSWERS.get_country_atlas_places },
|
||||
list_bucket_list: { answer: ATLAS_ANSWERS.list_bucket_list },
|
||||
mark_country_visited: markCountry,
|
||||
unmark_country_visited: unmarkCountry,
|
||||
mark_region_visited: markRegion,
|
||||
unmark_region_visited: unmarkRegion,
|
||||
create_bucket_list_item: createWish,
|
||||
update_bucket_list_item: updateWish,
|
||||
delete_bucket_list_item: deleteWish,
|
||||
},
|
||||
prompt: [
|
||||
'Atlas reads and changes are drawn as cards (flags, counts, the bucket list as tiles): answer in a sentence or two instead of listing them again.',
|
||||
'update_bucket_list_item and delete_bucket_list_item take the ids list_bucket_list gives; read it first when you have none.',
|
||||
],
|
||||
foreignIdGroups: ['atlas'],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import type { AtlasService } from './atlas.service';
|
||||
import type { VisitStatus } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Where the atlas stands after a write, as the MCP mark tools answer it: the
|
||||
* country's own status the way the Atlas map paints it (null once it is off
|
||||
* the map) and how many countries count as visited, plus the visited regions
|
||||
* of that country for the region tools. Read through `stats()` and
|
||||
* `visitedRegions()`, the same reads the Atlas page makes, so the assistant's
|
||||
* "that makes 24 countries" and the map agree. REST answers stay as they were.
|
||||
*/
|
||||
interface AtlasStanding {
|
||||
country_status: VisitStatus | null;
|
||||
countries_visited: number;
|
||||
}
|
||||
|
||||
export async function standingOf(
|
||||
atlas: Pick<AtlasService, 'stats'>,
|
||||
userId: number,
|
||||
code: string,
|
||||
): Promise<AtlasStanding> {
|
||||
const stats = await atlas.stats(userId);
|
||||
// A user without trips only has the countries marked by hand, which carry no status: they are visited.
|
||||
const countries: ReadonlyArray<{ code: string; status?: VisitStatus }> = stats.countries;
|
||||
const entry = countries.find((country) => country.code === code);
|
||||
return { country_status: entry ? (entry.status ?? 'visited') : null, countries_visited: stats.stats.totalCountries };
|
||||
}
|
||||
|
||||
/** The visited regions of one country, as the map paints them. */
|
||||
export async function regionsVisitedOf(
|
||||
atlas: Pick<AtlasService, 'visitedRegions'>,
|
||||
userId: number,
|
||||
countryCode: string,
|
||||
): Promise<number> {
|
||||
const { regions } = await atlas.visitedRegions(userId);
|
||||
return (regions[countryCode] ?? []).filter((region) => region.status === 'visited').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read made after a write that already landed and was broadcast: when it
|
||||
* fails, the answer leaves `after` out (the card falls back to what it held)
|
||||
* instead of turning a done write into a tool error the model would retry.
|
||||
*/
|
||||
export async function readAfterWrite<T>(read: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await read();
|
||||
} catch (err) {
|
||||
logWarn(`[atlas] reading the atlas back after a write failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { readAfterWrite, regionsVisitedOf, standingOf } from './atlas-standing.helpers';
|
||||
import { AtlasWritesService } from './atlas-writes.service';
|
||||
import { AtlasService, BucketItemExistsError } from './atlas.service';
|
||||
import { idSchema } from '@trek/shared';
|
||||
@@ -64,6 +65,12 @@ function jsonContent(uri: string, data: unknown) {
|
||||
* merged shape GET /regions has always returned instead of the raw manual
|
||||
* rows; and get_atlas_stats carries the dashboard passport figures from
|
||||
* GET /api/auth/travel-stats next to the atlas payload.
|
||||
*
|
||||
* For the assistant's cards the four mark tools also answer `after`: where
|
||||
* the country stands once the write ran and how many countries (and, for a
|
||||
* region, regions of that country) count as visited now (atlas-standing.helpers.ts;
|
||||
* a failed read leaves `after` out, the write stands), and list_bucket_list answers what GET /bucket-list does, the only way to
|
||||
* the ids update_bucket_list_item and delete_bucket_list_item take.
|
||||
*/
|
||||
@McpController()
|
||||
export class AtlasMcp {
|
||||
@@ -75,6 +82,21 @@ export class AtlasMcp {
|
||||
|
||||
// ── Bucket list ─────────────────────────────────────────────────────────
|
||||
|
||||
@Tool({
|
||||
name: 'list_bucket_list',
|
||||
description:
|
||||
'List your travel bucket list: each wish with its id, name, country and region, target date, notes and when it was reached. The ids are what update_bucket_list_item and delete_bucket_list_item take.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: atlasAddonOn,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
})
|
||||
async listBucketList(_args: Record<string, never>, ctx: McpContext) {
|
||||
// The same rows GET /bucket-list answers with, under the same `items` key.
|
||||
const items = await this.atlas.bucketList(ctx.userId);
|
||||
return ok({ items });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_bucket_list_item',
|
||||
description: 'Add a destination to your personal travel bucket list.',
|
||||
@@ -171,8 +193,10 @@ export class AtlasMcp {
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
})
|
||||
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
|
||||
await this.writes.markCountry(ctx.userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
const code = country_code.toUpperCase();
|
||||
await this.writes.markCountry(ctx.userId, code);
|
||||
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
|
||||
return ok({ success: true, country_code: code, after });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -186,8 +210,10 @@ export class AtlasMcp {
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
})
|
||||
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
|
||||
await this.writes.unmarkCountry(ctx.userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
const code = country_code.toUpperCase();
|
||||
await this.writes.unmarkCountry(ctx.userId, code);
|
||||
const after = await readAfterWrite(() => standingOf(this.atlas, ctx.userId, code));
|
||||
return ok({ success: true, country_code: code, after });
|
||||
}
|
||||
|
||||
// ── Atlas expanded ──────────────────────────────────────────────────────
|
||||
@@ -276,13 +302,14 @@ export class AtlasMcp {
|
||||
// (the legacy registrar passed them through verbatim, so a lowercase mark
|
||||
// created a row REST's uppercased unmark could never hit).
|
||||
const upperRegion = regionCode.toUpperCase();
|
||||
await this.writes.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase());
|
||||
const upperCountry = countryCode.toUpperCase();
|
||||
await this.writes.markRegion(ctx.userId, upperRegion, regionName, upperCountry);
|
||||
const row = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === upperRegion);
|
||||
// Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns.
|
||||
const region = row
|
||||
? { code: row.region_code, name: row.region_name, country_code: row.country_code, manuallyMarked: true }
|
||||
: undefined;
|
||||
return ok({ region });
|
||||
return ok({ region, after: await readAfterWrite(() => this.regionStanding(ctx.userId, upperCountry)) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -298,8 +325,19 @@ export class AtlasMcp {
|
||||
})
|
||||
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
|
||||
// Post-fold quirk fix: uppercase, matching the REST controller.
|
||||
await this.writes.unmarkRegion(ctx.userId, regionCode.toUpperCase());
|
||||
return ok({ success: true });
|
||||
const code = regionCode.toUpperCase();
|
||||
// The country is read before the unmark, which may take its last manual row
|
||||
// along; a region derived from places carries its country in the code.
|
||||
const marked = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === code);
|
||||
const countryCode = marked?.country_code ?? code.split('-')[0] ?? '';
|
||||
await this.writes.unmarkRegion(ctx.userId, code);
|
||||
return ok({ success: true, after: await readAfterWrite(() => this.regionStanding(ctx.userId, countryCode)) });
|
||||
}
|
||||
|
||||
/** Where a region's country stands after a region write, with that country's visited regions. */
|
||||
private async regionStanding(userId: number, countryCode: string) {
|
||||
const standing = await standingOf(this.atlas, userId, countryCode);
|
||||
return { ...standing, regions_visited: await regionsVisitedOf(this.atlas, userId, countryCode) };
|
||||
}
|
||||
|
||||
@Tool({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user