From da7af8337d89d9ab78feb3ec65a828e8ed2c13b2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 15:50:54 +0200 Subject: [PATCH] feat(assistant): passport, country and bucket list cards --- .../Assistant/cards/atlas/AtlasBucketCard.tsx | 169 ++++++ .../Assistant/cards/atlas/AtlasCards.test.tsx | 187 +++++++ .../cards/atlas/AtlasChanges.test.tsx | 172 ++++++ .../cards/atlas/AtlasCountryCard.tsx | 163 ++++++ .../Assistant/cards/atlas/AtlasParts.tsx | 58 ++ .../cards/atlas/AtlasPassportCard.tsx | 147 +++++ .../Assistant/cards/atlas/AtlasTally.tsx | 74 +++ .../cards/atlas/atlasCardModel.test.ts | 142 +++++ .../Assistant/cards/atlas/atlasCardModel.ts | 203 +++++++ .../Assistant/cards/atlas/atlasLook.ts | 60 +- .../Assistant/cards/atlas/atlasPage.ts | 8 + .../Assistant/cards/atlas/atlasViews.tsx | 33 +- .../Assistant/cards/atlas/useAtlasCard.ts | 19 + client/src/utils/countryName.test.ts | 21 + client/src/utils/countryName.ts | 25 + .../assistant/cards/atlas/atlas-answers.ts | 184 +++++++ .../assistant/cards/atlas/atlas-applied.ts | 109 ++++ .../assistant/cards/atlas/atlas-changes.ts | 315 +++++++++++ .../assistant/cards/atlas/atlas-values.ts | 201 +++++++ .../src/nest/assistant/cards/atlas/index.ts | 103 +++- .../src/nest/atlas/atlas-standing.helpers.ts | 52 ++ server/src/nest/atlas/atlas.mcp.ts | 54 +- .../unit/mcp/tools-atlas-expanded.test.ts | 17 +- server/tests/unit/mcp/tools-atlas.test.ts | 57 +- .../assistant/cards/atlas/atlas-cards.test.ts | 519 ++++++++++++++++++ .../src/assistant/cards/atlas-bucket.card.ts | 48 +- .../src/assistant/cards/atlas-cards.spec.ts | 107 ++++ .../src/assistant/cards/atlas-country.card.ts | 69 ++- .../assistant/cards/atlas-passport.card.ts | 50 +- shared/src/assistant/cards/atlas.contract.ts | 3 +- shared/src/i18n/ar/assistantAtlas.ts | 66 ++- shared/src/i18n/az/assistantAtlas.ts | 46 +- shared/src/i18n/br/assistantAtlas.ts | 46 +- shared/src/i18n/ca/assistantAtlas.ts | 46 +- shared/src/i18n/cs/assistantAtlas.ts | 51 +- shared/src/i18n/de/assistantAtlas.ts | 46 +- shared/src/i18n/en/assistantAtlas.ts | 46 +- shared/src/i18n/es/assistantAtlas.ts | 46 +- shared/src/i18n/et/assistantAtlas.ts | 46 +- shared/src/i18n/fr/assistantAtlas.ts | 46 +- shared/src/i18n/gr/assistantAtlas.ts | 46 +- shared/src/i18n/hu/assistantAtlas.ts | 46 +- shared/src/i18n/id/assistantAtlas.ts | 41 +- shared/src/i18n/it/assistantAtlas.ts | 46 +- shared/src/i18n/ja/assistantAtlas.ts | 41 +- shared/src/i18n/ko/assistantAtlas.ts | 41 +- shared/src/i18n/nl/assistantAtlas.ts | 46 +- shared/src/i18n/pl/assistantAtlas.ts | 56 +- shared/src/i18n/ru/assistantAtlas.ts | 56 +- shared/src/i18n/sk/assistantAtlas.ts | 51 +- shared/src/i18n/sv/assistantAtlas.ts | 46 +- shared/src/i18n/th/assistantAtlas.ts | 41 +- shared/src/i18n/tr/assistantAtlas.ts | 46 +- shared/src/i18n/uk/assistantAtlas.ts | 56 +- shared/src/i18n/vi/assistantAtlas.ts | 41 +- shared/src/i18n/zh-TW/assistantAtlas.ts | 41 +- shared/src/i18n/zh/assistantAtlas.ts | 41 +- 57 files changed, 4564 insertions(+), 72 deletions(-) create mode 100644 client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasParts.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasPassportCard.tsx create mode 100644 client/src/components/Assistant/cards/atlas/AtlasTally.tsx create mode 100644 client/src/components/Assistant/cards/atlas/atlasCardModel.test.ts create mode 100644 client/src/components/Assistant/cards/atlas/atlasCardModel.ts create mode 100644 client/src/components/Assistant/cards/atlas/atlasPage.ts create mode 100644 client/src/components/Assistant/cards/atlas/useAtlasCard.ts create mode 100644 client/src/utils/countryName.test.ts create mode 100644 client/src/utils/countryName.ts create mode 100644 server/src/nest/assistant/cards/atlas/atlas-answers.ts create mode 100644 server/src/nest/assistant/cards/atlas/atlas-applied.ts create mode 100644 server/src/nest/assistant/cards/atlas/atlas-changes.ts create mode 100644 server/src/nest/assistant/cards/atlas/atlas-values.ts create mode 100644 server/src/nest/atlas/atlas-standing.helpers.ts create mode 100644 server/tests/unit/nest/assistant/cards/atlas/atlas-cards.test.ts create mode 100644 shared/src/assistant/cards/atlas-cards.spec.ts diff --git a/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx b/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx new file mode 100644 index 000000000..6bc5aa3d7 --- /dev/null +++ b/client/src/components/Assistant/cards/atlas/AtlasBucketCard.tsx @@ -0,0 +1,169 @@ +import type { AssistantMessage, AtlasBucketCard as BucketCard, AtlasBucketEntry } from '@trek/shared'; +import { CalendarDays, Check, NotebookPen, Star, Tag, TriangleAlert } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import CountryFlag from '../../../shared/CountryFlag'; +import type { ChangeItem } from '../../assistantParts'; +import type { CardEntry } from '../cardTypes'; +import { FactChange } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { CardMore } from '../parts/CardRows'; +import { shortDay, wishMonth } from './atlasCardModel'; +import { ATLAS_PAGE } from './atlasPage'; +import { ChangeFlag } from './AtlasParts'; +import { useAtlasCard } from './useAtlasCard'; + +/** + * The bucket list the way the Atlas page lists it: each wish as a tile with + * its flag (a star without a country), its name, the month it is planned for + * and its notes, a tick with the day for one that came true. In a change card + * the one wish it adds, changes or removes is the row. + */ + +const ICON = { size: 10, strokeWidth: 2.2, 'aria-hidden': true } as const; + +function WishTile({ wish }: { wish: AtlasBucketEntry }) { + const { t } = useTranslation(); + const { countryName, locale } = useAtlasCard(); + const month = wishMonth(wish.targetDate, locale); + return ( +
  • + + {wish.countryCode ? ( + + + + ) : ( + + + {wish.name} + + {month && ( + }>{month} + )} + {wish.notes &&

    {wish.notes}

    } + {wish.reachedOn && ( + + + )} +
  • + ); +} + +/** + * The wishes of an answer as tiles; two reads in one answer share the tiles, + * each wish once. The list is as long as the longest read says it is (what a + * read shows plus what it left out), never shorter than the wishes shown. + */ +export default function AtlasBucketBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) { + const { t } = useTranslation(); + const { roomy, limits } = useAtlasCard(); + const seen = new Set(); + const wishes: AtlasBucketEntry[] = []; + let total = 0; + for (const entry of cards) { + if (entry.card.kind !== 'atlasBucket') continue; + total = Math.max(total, entry.card.items.length + entry.card.more); + for (const wish of entry.card.items) { + const key = wish.id === null ? `new-${wish.name}` : String(wish.id); + if (seen.has(key)) continue; + seen.add(key); + wishes.push(wish); + } + } + if (wishes.length === 0) return null; + const shown = wishes.slice(0, limits.wishes); + const count = Math.max(total, wishes.length); + return ( + + } + /> +
      + {shown.map((wish) => ( + + ))} +
    +
    + +
    +
    + ); +} + +/** A wish change's lead: its flag, or a star without a country, with what the change does on its corner. */ +export function AtlasBucketLead({ card, item }: { card: BucketCard; item: ChangeItem }) { + return ; +} + +/** + * When a wish is planned for (a date taken away shows struck through) and the + * region it marks; that an add would repeat a wish already there. + */ +export function AtlasBucketFacts({ card }: { card: BucketCard; item: ChangeItem }) { + const { t } = useTranslation(); + const { locale } = useAtlasCard(); + const wish = card.items[0]; + const before = card.change?.op === 'update' ? card.change.before : null; + const month = wishMonth(wish.targetDate, locale); + const monthBefore = before ? wishMonth(before.targetDate, locale) : null; + return ( + <> + {month && ( + + )} + {!month && monthBefore && ( + + )} + {wish.regionCode && ( + }>{wish.regionCode} + )} + {card.change?.duplicate && ( + }> + {t('assistant.card.atlas.duplicate')} + + )} + + ); +} + +/** + * Under a wish change: the notes of a new wish, the name and notes a change + * gives an old one (notes taken away show struck through). + */ +export function AtlasBucketBody({ card }: { card: BucketCard; item: ChangeItem }) { + const { t } = useTranslation(); + const wish = card.items[0]; + const change = card.change; + if (change?.op === 'update' && change.before) { + const before = change.before; + const renamed = before.name !== wish.name; + const renoted = before.notes !== wish.notes; + if (!renamed && !renoted) return null; + return ( + + {renamed && } + {renoted && ( + + )} + + ); + } + if (change?.op !== 'add' || !wish.notes) return null; + return

    {wish.notes}

    ; +} diff --git a/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx b/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx new file mode 100644 index 000000000..0e91370e4 --- /dev/null +++ b/client/src/components/Assistant/cards/atlas/AtlasCards.test.tsx @@ -0,0 +1,187 @@ +/** + * FE-AI-ATLAS-001 to FE-AI-ATLAS-006 + * + * The atlas answer cards as the chat draws them: the passport with its + * figures, flags, continents, next trip and streak; a country with its trips + * and places; the bucket list as wish tiles; every one leading to the Atlas, + * and the pure look that names the atlas tools, steps and follow-ups. + */ +import type { AssistantMessage, AtlasBucketEntry, AtlasCountryCard, AtlasPassportCard, TrekCard } from '@trek/shared'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import { CARD_LOOKS, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks'; +import type { CardEntry } from '../cardTypes'; +import AtlasBucketBlock from './AtlasBucketCard'; +import AtlasCountryBlock from './AtlasCountryCard'; +import AtlasPassportBlock from './AtlasPassportCard'; + +const MESSAGE = { id: 'm1', role: 'assistant', parts: [] } as unknown as AssistantMessage; +const CODES = 'JP FR IT ES PT GR HR AT CH DE NL BE NO SE DK IS FI PL'.split(' '); + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render({ui}); +} + +const entries = (...cards: TrekCard[]): CardEntry[] => + cards.map((card, index) => ({ toolCallId: `call_${index}`, card })); + +const passport: AtlasPassportCard = { + kind: 'atlasPassport', + countries: { visited: 23, planned: 4, idea: 0 }, + totals: { trips: 48, days: 410, places: 990, cities: 312, flownKm: 182_300 }, + flags: [ + ...CODES.map((code) => ({ code, status: 'visited' as const })), + { code: 'MA', status: 'planned' as const }, + ], + moreFlags: 4, + continents: [ + { name: 'Europe', visited: 14, planned: 2 }, + { name: 'Asia', visited: 5, planned: 0 }, + { name: 'Africa', visited: 0, planned: 1 }, + ], + nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 }, + streakYears: 3, +}; + +const japan: AtlasCountryCard = { + kind: 'atlasCountry', + code: 'JP', + status: 'planned', + markedBy: 'dawarich', + placeCount: 14, + tripCount: 2, + trips: [ + { id: 1, title: 'Kyoto in spring', startDate: '2024-04-03', endDate: '2024-04-14' }, + { id: 2, title: 'Tokyo food week', startDate: '2019-10-12', endDate: null }, + ], + moreTrips: 1, + places: ['Fushimi Inari', 'Kinkaku-ji'], + morePlaces: 12, + change: null, +}; + +function wish(id: number | null, name: string, extra: Partial = {}): AtlasBucketEntry { + return { + id, + name, + countryCode: 'IS', + regionCode: null, + targetDate: null, + notes: null, + reachedOn: null, + reachedFrom: null, + ...extra, + }; +} + +describe('atlas answer cards', () => { + it('FE-AI-ATLAS-001: the passport shows the visited number, the figures, sixteen flags and the rest counted', () => { + const onOpen = vi.fn(); + inView(, { onOpen }); + expect(screen.getByRole('region', { name: 'Your atlas' })).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); + expect(screen.getByText('4 planned')).toBeInTheDocument(); + for (const label of ['Trips', 'Days', 'Cities', 'Flown']) expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText('182300 km')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Morocco, Planned' })).toBeNull(); + expect(screen.getByText('7 more')).toBeInTheDocument(); + expect(screen.getByText('Europe')).toBeInTheDocument(); + expect(screen.queryByText('Africa')).toBeNull(); + expect(screen.getByText('Next trip: Lisbon in 12 days')).toBeInTheDocument(); + expect(screen.getByText('3 years in a row')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '7 more' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' }); + fireEvent.click(screen.getByRole('button', { name: /Open Atlas/ })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' }); + }); + + it('FE-AI-ATLAS-002: a roomy window shows more flags, a trip-less user only the number and the flags, nothing flown no figure', () => { + const roomy = inView(, { density: 'roomy' }); + expect(screen.getByRole('img', { name: 'Morocco, Planned' })).toHaveClass('opacity-60'); + expect(screen.getByText('4 more')).toBeInTheDocument(); + roomy.unmount(); + + const tripless = { + ...passport, + totals: { trips: 0, days: 0, places: 0, cities: 0, flownKm: null }, + continents: [], + nextTrip: null, + streakYears: null, + }; + inView(, { variant: 'fullscreen' }); + expect(screen.queryByText('Trips')).toBeNull(); + expect(screen.queryByText('Flown')).toBeNull(); + expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument(); + expect(screen.queryByText(/Next trip/)).toBeNull(); + }); + + it('FE-AI-ATLAS-003: a country reads its standing, continent, counts, latest trips, places and its recordings', () => { + const korea: TrekCard = { ...japan, code: 'KR', status: null, markedBy: null }; + inView(); + expect(screen.getByText('Japan')).toBeInTheDocument(); + expect(screen.getByText('South Korea')).toBeInTheDocument(); + expect(screen.getByText('Planned')).toBeInTheDocument(); + expect(screen.getByText('Not on your map')).toBeInTheDocument(); + expect(screen.getAllByText('Asia')).toHaveLength(2); + expect(screen.getAllByText('2 trips')).toHaveLength(2); + expect(screen.getAllByText('14 places')).toHaveLength(2); + expect(screen.getAllByText('Apr 3, 2024 to Apr 14, 2024')).toHaveLength(2); + expect(screen.getAllByText('Oct 12, 2019')).toHaveLength(2); + expect(screen.getAllByText('12 more')).toHaveLength(2); + expect(screen.getAllByText('1 more')).toHaveLength(2); + expect(screen.getAllByText('From your recordings')).toHaveLength(1); + }); + + it('FE-AI-ATLAS-004: the bucket list shows six tiles on the phone, each wish once, counted by its longest read', () => { + const onOpen = vi.fn(); + const first: TrekCard = { + kind: 'atlasBucket', + items: [ + wish(1, 'Northern lights', { targetDate: '2027-02', notes: 'Glacier hike' }), + wish(2, 'Petra', { countryCode: null, reachedOn: '2025-04-02', reachedFrom: 'dawarich' }), + ...[3, 4, 5].map((id) => wish(id, `Wish ${id}`)), + ], + more: 3, + change: null, + }; + const later = [wish(5, 'Wish 5'), wish(6, 'Wish 6'), wish(7, 'Wish 7')]; + const second: TrekCard = { kind: 'atlasBucket', items: later, more: 0, change: null }; + inView(, { variant: 'fullscreen', onOpen }); + expect(screen.getByRole('region', { name: 'Bucket List' })).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(6); + expect(screen.getByText('Feb 2027')).toBeInTheDocument(); + expect(screen.getByText('Glacier hike')).toBeInTheDocument(); + expect(screen.getByText('Reached on Apr 2, 2025')).toBeInTheDocument(); + expect(screen.queryByText('Wish 7')).toBeNull(); + // The first read knows 8 wishes (5 shown, 3 left out), the second only 3: 8 in all, 6 shown, 2 more. + fireEvent.click(screen.getByRole('button', { name: '2 more' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' }); + }); + + it('FE-AI-ATLAS-005: blocks of another kind or without a card of theirs draw nothing', () => { + const other = entries({ kind: 'tourList' } as TrekCard); + const { container } = inView( + <> + + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-AI-ATLAS-006: the look names the atlas tools and reads, opens the Atlas for an applied change and asks next', () => { + expect(TOOL_LABELS.unmark_region_visited?.labelKey).toBe('assistant.tool.unmark_region_visited'); + expect(TOOL_LABELS.mark_country_visited?.labelKey).toBe('assistant.tool.mark_country_visited'); + expect(STEP_LOOKS.list_bucket_list?.stepKey).toBe('assistant.step.bucketList'); + const applied = { toolCallId: 'c', entity: 'atlas' as const, ids: [], dayId: null, undoUntil: null }; + expect(ENTITY_LOOK.atlas.open?.(applied, null)).toEqual({ kind: 'page', path: '/atlas' }); + expect(ENTITY_LOOK.atlas.onMap).toBeUndefined(); + expect(CARD_LOOKS.atlasBucket.followUps?.map((followUp) => followUp.id)).toEqual(['atlasPlanWish', 'atlasAddWish']); + }); +}); diff --git a/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx b/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx new file mode 100644 index 000000000..c9a68dcdf --- /dev/null +++ b/client/src/components/Assistant/cards/atlas/AtlasChanges.test.tsx @@ -0,0 +1,172 @@ +/** + * FE-AI-ATLAS-CHANGE-001 to FE-AI-ATLAS-CHANGE-004 + * + * Atlas changes inside the change card, drawn by the atlas views: each + * country or wish a row with its flag and standing, the run headed by the + * counters it moves. Switching a row off moves the counter at once; applied, + * the counter is the exact count and the receipt opens the Atlas. + */ +import type { AssistantApplied, AssistantChangePreview, AtlasBucketCard, AtlasCountryCard } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../../../store/assistantStore'; +import AssistantChangeCard from '../../AssistantChangeCard'; +import type { ChangeItem } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; + +const TRIP = 4; +const INITIAL = useAssistantStore.getState(); + +function preview(toolCallId: string, subject: string, tool: string, remove = false): AssistantChangePreview { + return { + toolCallId, + verbKey: remove ? 'genericRemove' : 'genericCreate', + subject, + rows: [], + dayId: null, + placeIds: [], + undoable: false, + tool, + domain: 'atlas', + }; +} + +function country( + code: string, + status: AtlasCountryCard['status'], + change: Pick, 'op' | 'after' | 'countries'> +): AtlasCountryCard { + return { + kind: 'atlasCountry', + code, + status, + markedBy: null, + placeCount: 0, + tripCount: 0, + trips: [], + moreTrips: 0, + places: [], + morePlaces: 0, + change: { ...change, region: null, regions: null }, + }; +} + +const JAPAN = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } }); +const KOREA = country('KR', null, { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } }); +const THAILAND = country('TH', 'visited', { op: 'unmark', after: 'none', countries: { before: 23, after: 22 } }); + +function pending(id: string, subject: string, tool: string, card: ChangeItem['card'], remove = false): ChangeItem { + return { toolCallId: id, preview: preview(id, subject, tool, remove), applied: null, state: 'pending', card }; +} + +function renderCard(items: ChangeItem[], view: Partial = {}) { + const value: AssistantView = { tripId: TRIP, variant: 'window', ...view }; + return render( + + + + ); +} + +beforeEach(() => { + useAssistantStore.setState({ + decide: vi.fn(async () => {}), + byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } }, + }); +}); + +afterEach(() => { + useAssistantStore.setState(INITIAL, true); + vi.clearAllMocks(); +}); + +describe('atlas changes', () => { + it('FE-AI-ATLAS-CHANGE-001: three country changes share one counter, and switching one off moves it at once', () => { + renderCard([ + pending('c1', 'Japan', 'mark_country_visited', JAPAN), + pending('c2', 'South Korea', 'mark_country_visited', KOREA), + pending('c3', 'Thailand', 'unmark_country_visited', THAILAND, true), + ]); + expect(screen.getByText('Countries visited')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); + expect(screen.getByText('24')).toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); + expect(screen.getAllByText('Mark the country as visited')).toHaveLength(2); + expect(screen.getByText('Remove a visited country')).toBeInTheDocument(); + expect(screen.getAllByText('Atlas')).toHaveLength(3); + // Japan was planned, Korea not on the map, Thailand leaves it. + expect(screen.getByText('Planned')).toHaveClass('line-through'); + expect(screen.getAllByText('Visited')).toHaveLength(3); + expect(screen.getByText('Not on your map')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Include this change' })[2] as HTMLElement); + expect(screen.getByText('25')).toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('FE-AI-ATLAS-CHANGE-002: applied, the counter is the exact count and the receipt opens the Atlas', () => { + const onOpen = vi.fn(); + const applied: AssistantApplied = { toolCallId: 'c1', entity: 'atlas', ids: [], dayId: null, undoUntil: null }; + const exact = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } }); + renderCard([{ ...pending('c1', 'Japan', 'mark_country_visited', exact), applied, state: 'applied' }], { onOpen }); + expect(screen.getByText('24')).toBeInTheDocument(); + expect(screen.queryByText('+1')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Open Atlas' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' }); + }); + + it('FE-AI-ATLAS-CHANGE-003: a wish to add shows its month, its notes and that it is on the list already', () => { + const card: AtlasBucketCard = { + kind: 'atlasBucket', + items: [ + { + id: null, + name: 'Northern lights', + countryCode: null, + regionCode: null, + targetDate: '2027-02', + notes: 'Glacier hike and hot springs', + reachedOn: null, + reachedFrom: null, + }, + ], + more: 0, + change: { op: 'add', before: null, duplicate: true, wishes: { before: 7, after: 7 } }, + }; + renderCard([pending('w1', 'Northern lights', 'create_bucket_list_item', card)]); + expect(screen.getByText('Add to the bucket list')).toBeInTheDocument(); + expect(screen.getByText('Feb 2027')).toBeInTheDocument(); + expect(screen.getByText('Already on your list')).toBeInTheDocument(); + expect(screen.getByText('Glacier hike and hot springs')).toBeInTheDocument(); + expect(screen.getByText('Bucket list')).toBeInTheDocument(); + expect(screen.getAllByText('7')).toHaveLength(2); + }); + + it('FE-AI-ATLAS-CHANGE-004: a wish change strikes through the date and the notes it takes away and shows a new name', () => { + const before = { + id: 5, + name: 'Northern lights', + countryCode: 'IS', + regionCode: null, + targetDate: '2027-02', + notes: 'Glacier hike', + reachedOn: null, + reachedFrom: null, + }; + const card: AtlasBucketCard = { + kind: 'atlasBucket', + items: [{ ...before, name: 'Aurora', targetDate: null, notes: null }], + more: 0, + change: { op: 'update', before, duplicate: false, wishes: null }, + }; + renderCard([pending('w2', 'Aurora', 'update_bucket_list_item', card)]); + expect(screen.getByText('Feb 2027')).toHaveClass('line-through'); + expect(screen.getByText('Glacier hike')).toHaveClass('line-through'); + expect(screen.getAllByText('Removed')).toHaveLength(2); + expect(screen.getByText('Northern lights')).toHaveClass('line-through'); + expect(screen.getAllByText('Aurora').length).toBeGreaterThan(0); + // No counter heads a wish change: the size of the list does not move. + expect(screen.queryByText('Bucket list')).toBeNull(); + }); +}); diff --git a/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx b/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx new file mode 100644 index 000000000..1a2d421e4 --- /dev/null +++ b/client/src/components/Assistant/cards/atlas/AtlasCountryCard.tsx @@ -0,0 +1,163 @@ +import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared'; +import { Briefcase, MapPin, Radio } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import CountryFlag from '../../../shared/CountryFlag'; +import type { ChangeItem } from '../../assistantParts'; +import type { CardEntry } from '../cardTypes'; +import { CardFacts } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import { CardOpenButton } from '../parts/CardOpen'; +import { CardMore } from '../parts/CardRows'; +import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel'; +import { ATLAS_PAGE } from './atlasPage'; +import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts'; +import { useAtlasCard } from './useAtlasCard'; + +/** + * One country of the atlas the way the Atlas page's country sheet shows it: + * the flag and the name with its standing, the continent and how many trips + * and places put it there, the latest trips with their dates, the places as + * badges, and whether a recording ticked it. In a change card the same + * country is the row: its flag as the lead, its standing from now to after. + */ + +type Translate = ReturnType['t']; + +/** A trip's dates as "3 Apr 2024 to 14 Apr 2024", one day alone, or nothing. */ +function tripDates(start: string | null, end: string | null, locale: string, t: Translate): string | null { + if (start && end && start !== end) { + return t('assistant.card.atlas.dates', { from: shortDay(start, locale), to: shortDay(end, locale) }); + } + const day = start ?? end; + return day ? shortDay(day, locale) : null; +} + +function CountrySection({ card }: { card: CountryCard }) { + const { t } = useTranslation(); + const { countryName, locale } = useAtlasCard(); + const status = STATUS_LOOK[card.status ?? 'none']; + const trips = card.tripCount ?? 0; + const places = card.placeCount ?? 0; + return ( +
    +
    + +
    +
    + + {countryName(card.code)} + + +
    + + + {t(CONTINENT_LABEL[continentOf(card.code)].labelKey)} + + {trips > 0 && ( + + )} + {places > 0 && ( + + )} + +
    +
    + {card.trips.length > 0 && ( +
      + {card.trips.map((trip) => ( +
    • + + {trip.title} + + + {tripDates(trip.startDate, trip.endDate, locale, t)} + +
    • + ))} +
    + )} + + {card.places.length > 0 && ( +
    + {card.places.map((place) => ( + + {place} + + ))} + +
    + )} + {card.markedBy === 'dawarich' && ( +

    +

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