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

This commit is contained in:
Maurice
2026-10-22 15:50:54 +02:00
parent fd15a7343e
commit da7af8337d
57 changed files with 4564 additions and 72 deletions
@@ -0,0 +1,169 @@
import type { AssistantMessage, AtlasBucketCard as BucketCard, AtlasBucketEntry } from '@trek/shared';
import { CalendarDays, Check, NotebookPen, Star, Tag, TriangleAlert } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import CountryFlag from '../../../shared/CountryFlag';
import type { ChangeItem } from '../../assistantParts';
import type { CardEntry } from '../cardTypes';
import { FactChange } from '../parts/CardFacts';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { CardOpenButton } from '../parts/CardOpen';
import { CardMore } from '../parts/CardRows';
import { shortDay, wishMonth } from './atlasCardModel';
import { ATLAS_PAGE } from './atlasPage';
import { ChangeFlag } from './AtlasParts';
import { useAtlasCard } from './useAtlasCard';
/**
* The bucket list the way the Atlas page lists it: each wish as a tile with
* its flag (a star without a country), its name, the month it is planned for
* and its notes, a tick with the day for one that came true. In a change card
* the one wish it adds, changes or removes is the row.
*/
const ICON = { size: 10, strokeWidth: 2.2, 'aria-hidden': true } as const;
function WishTile({ wish }: { wish: AtlasBucketEntry }) {
const { t } = useTranslation();
const { countryName, locale } = useAtlasCard();
const month = wishMonth(wish.targetDate, locale);
return (
<li className="flex min-w-0 flex-col items-start gap-1 rounded-2xl border border-edge-faint bg-surface-card p-2.5 shadow-sm">
<span className="flex h-5 w-full items-center justify-between gap-2">
{wish.countryCode ? (
<span role="img" aria-label={countryName(wish.countryCode)}>
<CountryFlag code={wish.countryCode} size={18} />
</span>
) : (
<Star size={16} strokeWidth={2.1} aria-hidden="true" className="text-warning" />
)}
</span>
<bdi className="line-clamp-2 w-full break-words text-body font-semibold leading-tight text-content">
{wish.name}
</bdi>
{month && (
<WhiteBadge icon={<CalendarDays {...ICON} className="flex-none text-content-faint" />}>{month}</WhiteBadge>
)}
{wish.notes && <p className="m-0 line-clamp-2 w-full text-caption text-content-muted">{wish.notes}</p>}
{wish.reachedOn && (
<span className="flex items-center gap-1 text-caption text-success">
<Check size={12} strokeWidth={2.4} aria-hidden="true" className="flex-none" />
{t('assistant.card.atlas.reached', { date: shortDay(wish.reachedOn, locale) })}
</span>
)}
</li>
);
}
/**
* The wishes of an answer as tiles; two reads in one answer share the tiles,
* each wish once. The list is as long as the longest read says it is (what a
* read shows plus what it left out), never shorter than the wishes shown.
*/
export default function AtlasBucketBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const { t } = useTranslation();
const { roomy, limits } = useAtlasCard();
const seen = new Set<string>();
const wishes: AtlasBucketEntry[] = [];
let total = 0;
for (const entry of cards) {
if (entry.card.kind !== 'atlasBucket') continue;
total = Math.max(total, entry.card.items.length + entry.card.more);
for (const wish of entry.card.items) {
const key = wish.id === null ? `new-${wish.name}` : String(wish.id);
if (seen.has(key)) continue;
seen.add(key);
wishes.push(wish);
}
}
if (wishes.length === 0) return null;
const shown = wishes.slice(0, limits.wishes);
const count = Math.max(total, wishes.length);
return (
<CardFrame label={t('atlas.bucketTab')}>
<CardHeader
icon={Star}
tone="warning"
title={t('atlas.bucketTab')}
count={count}
actions={<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />}
/>
<ul className={`m-0 mt-2.5 grid list-none gap-2 p-0 ${roomy ? 'grid-cols-3' : 'grid-cols-2'}`}>
{shown.map((wish) => (
<WishTile key={wish.id ?? `new-${wish.name}`} wish={wish} />
))}
</ul>
<div className="mt-1">
<CardMore count={count - shown.length} target={ATLAS_PAGE} />
</div>
</CardFrame>
);
}
/** A wish change's lead: its flag, or a star without a country, with what the change does on its corner. */
export function AtlasBucketLead({ card, item }: { card: BucketCard; item: ChangeItem }) {
return <ChangeFlag code={card.items[0].countryCode} item={item} />;
}
/**
* When a wish is planned for (a date taken away shows struck through) and the
* region it marks; that an add would repeat a wish already there.
*/
export function AtlasBucketFacts({ card }: { card: BucketCard; item: ChangeItem }) {
const { t } = useTranslation();
const { locale } = useAtlasCard();
const wish = card.items[0];
const before = card.change?.op === 'update' ? card.change.before : null;
const month = wishMonth(wish.targetDate, locale);
const monthBefore = before ? wishMonth(before.targetDate, locale) : null;
return (
<>
{month && (
<FactChange icon={CalendarDays} before={before && monthBefore !== month ? monthBefore : null} after={month} />
)}
{!month && monthBefore && (
<FactChange icon={CalendarDays} before={monthBefore} after={t('assistant.card.common.removed')} />
)}
{wish.regionCode && (
<WhiteBadge icon={<Tag {...ICON} className="flex-none text-content-faint" />}>{wish.regionCode}</WhiteBadge>
)}
{card.change?.duplicate && (
<SoftPill tone="warning" icon={<TriangleAlert {...ICON} />}>
{t('assistant.card.atlas.duplicate')}
</SoftPill>
)}
</>
);
}
/**
* Under a wish change: the notes of a new wish, the name and notes a change
* gives an old one (notes taken away show struck through).
*/
export function AtlasBucketBody({ card }: { card: BucketCard; item: ChangeItem }) {
const { t } = useTranslation();
const wish = card.items[0];
const change = card.change;
if (change?.op === 'update' && change.before) {
const before = change.before;
const renamed = before.name !== wish.name;
const renoted = before.notes !== wish.notes;
if (!renamed && !renoted) return null;
return (
<span className="flex flex-wrap items-center gap-1">
{renamed && <FactChange icon={Star} before={before.name} after={wish.name} />}
{renoted && (
<FactChange
icon={NotebookPen}
before={before.notes}
after={wish.notes ?? t('assistant.card.common.removed')}
/>
)}
</span>
);
}
if (change?.op !== 'add' || !wish.notes) return null;
return <p className="m-0 line-clamp-2 text-caption text-content-muted">{wish.notes}</p>;
}
@@ -0,0 +1,187 @@
/**
* FE-AI-ATLAS-001 to FE-AI-ATLAS-006
*
* The atlas answer cards as the chat draws them: the passport with its
* figures, flags, continents, next trip and streak; a country with its trips
* and places; the bucket list as wish tiles; every one leading to the Atlas,
* and the pure look that names the atlas tools, steps and follow-ups.
*/
import type { AssistantMessage, AtlasBucketEntry, AtlasCountryCard, AtlasPassportCard, TrekCard } from '@trek/shared';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import { CARD_LOOKS, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks';
import type { CardEntry } from '../cardTypes';
import AtlasBucketBlock from './AtlasBucketCard';
import AtlasCountryBlock from './AtlasCountryCard';
import AtlasPassportBlock from './AtlasPassportCard';
const MESSAGE = { id: 'm1', role: 'assistant', parts: [] } as unknown as AssistantMessage;
const CODES = 'JP FR IT ES PT GR HR AT CH DE NL BE NO SE DK IS FI PL'.split(' ');
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
const entries = (...cards: TrekCard[]): CardEntry[] =>
cards.map((card, index) => ({ toolCallId: `call_${index}`, card }));
const passport: AtlasPassportCard = {
kind: 'atlasPassport',
countries: { visited: 23, planned: 4, idea: 0 },
totals: { trips: 48, days: 410, places: 990, cities: 312, flownKm: 182_300 },
flags: [
...CODES.map((code) => ({ code, status: 'visited' as const })),
{ code: 'MA', status: 'planned' as const },
],
moreFlags: 4,
continents: [
{ name: 'Europe', visited: 14, planned: 2 },
{ name: 'Asia', visited: 5, planned: 0 },
{ name: 'Africa', visited: 0, planned: 1 },
],
nextTrip: { title: 'Lisbon', startDate: '2026-11-03', daysUntil: 12 },
streakYears: 3,
};
const japan: AtlasCountryCard = {
kind: 'atlasCountry',
code: 'JP',
status: 'planned',
markedBy: 'dawarich',
placeCount: 14,
tripCount: 2,
trips: [
{ id: 1, title: 'Kyoto in spring', startDate: '2024-04-03', endDate: '2024-04-14' },
{ id: 2, title: 'Tokyo food week', startDate: '2019-10-12', endDate: null },
],
moreTrips: 1,
places: ['Fushimi Inari', 'Kinkaku-ji'],
morePlaces: 12,
change: null,
};
function wish(id: number | null, name: string, extra: Partial<AtlasBucketEntry> = {}): AtlasBucketEntry {
return {
id,
name,
countryCode: 'IS',
regionCode: null,
targetDate: null,
notes: null,
reachedOn: null,
reachedFrom: null,
...extra,
};
}
describe('atlas answer cards', () => {
it('FE-AI-ATLAS-001: the passport shows the visited number, the figures, sixteen flags and the rest counted', () => {
const onOpen = vi.fn();
inView(<AtlasPassportBlock cards={entries(passport)} message={MESSAGE} />, { onOpen });
expect(screen.getByRole('region', { name: 'Your atlas' })).toBeInTheDocument();
expect(screen.getByText('23')).toBeInTheDocument();
expect(screen.getByText('4 planned')).toBeInTheDocument();
for (const label of ['Trips', 'Days', 'Cities', 'Flown']) expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText('182300 km')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'Morocco, Planned' })).toBeNull();
expect(screen.getByText('7 more')).toBeInTheDocument();
expect(screen.getByText('Europe')).toBeInTheDocument();
expect(screen.queryByText('Africa')).toBeNull();
expect(screen.getByText('Next trip: Lisbon in 12 days')).toBeInTheDocument();
expect(screen.getByText('3 years in a row')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '7 more' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
fireEvent.click(screen.getByRole('button', { name: /Open Atlas/ }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
});
it('FE-AI-ATLAS-002: a roomy window shows more flags, a trip-less user only the number and the flags, nothing flown no figure', () => {
const roomy = inView(<AtlasPassportBlock cards={entries(passport)} message={MESSAGE} />, { density: 'roomy' });
expect(screen.getByRole('img', { name: 'Morocco, Planned' })).toHaveClass('opacity-60');
expect(screen.getByText('4 more')).toBeInTheDocument();
roomy.unmount();
const tripless = {
...passport,
totals: { trips: 0, days: 0, places: 0, cities: 0, flownKm: null },
continents: [],
nextTrip: null,
streakYears: null,
};
inView(<AtlasPassportBlock cards={entries(passport, tripless)} message={MESSAGE} />, { variant: 'fullscreen' });
expect(screen.queryByText('Trips')).toBeNull();
expect(screen.queryByText('Flown')).toBeNull();
expect(screen.getByRole('img', { name: 'Japan, Visited' })).toBeInTheDocument();
expect(screen.queryByText(/Next trip/)).toBeNull();
});
it('FE-AI-ATLAS-003: a country reads its standing, continent, counts, latest trips, places and its recordings', () => {
const korea: TrekCard = { ...japan, code: 'KR', status: null, markedBy: null };
inView(<AtlasCountryBlock cards={entries(japan, korea)} message={MESSAGE} />);
expect(screen.getByText('Japan')).toBeInTheDocument();
expect(screen.getByText('South Korea')).toBeInTheDocument();
expect(screen.getByText('Planned')).toBeInTheDocument();
expect(screen.getByText('Not on your map')).toBeInTheDocument();
expect(screen.getAllByText('Asia')).toHaveLength(2);
expect(screen.getAllByText('2 trips')).toHaveLength(2);
expect(screen.getAllByText('14 places')).toHaveLength(2);
expect(screen.getAllByText('Apr 3, 2024 to Apr 14, 2024')).toHaveLength(2);
expect(screen.getAllByText('Oct 12, 2019')).toHaveLength(2);
expect(screen.getAllByText('12 more')).toHaveLength(2);
expect(screen.getAllByText('1 more')).toHaveLength(2);
expect(screen.getAllByText('From your recordings')).toHaveLength(1);
});
it('FE-AI-ATLAS-004: the bucket list shows six tiles on the phone, each wish once, counted by its longest read', () => {
const onOpen = vi.fn();
const first: TrekCard = {
kind: 'atlasBucket',
items: [
wish(1, 'Northern lights', { targetDate: '2027-02', notes: 'Glacier hike' }),
wish(2, 'Petra', { countryCode: null, reachedOn: '2025-04-02', reachedFrom: 'dawarich' }),
...[3, 4, 5].map((id) => wish(id, `Wish ${id}`)),
],
more: 3,
change: null,
};
const later = [wish(5, 'Wish 5'), wish(6, 'Wish 6'), wish(7, 'Wish 7')];
const second: TrekCard = { kind: 'atlasBucket', items: later, more: 0, change: null };
inView(<AtlasBucketBlock cards={entries(first, second)} message={MESSAGE} />, { variant: 'fullscreen', onOpen });
expect(screen.getByRole('region', { name: 'Bucket List' })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(6);
expect(screen.getByText('Feb 2027')).toBeInTheDocument();
expect(screen.getByText('Glacier hike')).toBeInTheDocument();
expect(screen.getByText('Reached on Apr 2, 2025')).toBeInTheDocument();
expect(screen.queryByText('Wish 7')).toBeNull();
// The first read knows 8 wishes (5 shown, 3 left out), the second only 3: 8 in all, 6 shown, 2 more.
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
});
it('FE-AI-ATLAS-005: blocks of another kind or without a card of theirs draw nothing', () => {
const other = entries({ kind: 'tourList' } as TrekCard);
const { container } = inView(
<>
<AtlasPassportBlock cards={other} message={MESSAGE} />
<AtlasCountryBlock cards={other} message={MESSAGE} />
<AtlasBucketBlock cards={other} message={MESSAGE} />
</>
);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-ATLAS-006: the look names the atlas tools and reads, opens the Atlas for an applied change and asks next', () => {
expect(TOOL_LABELS.unmark_region_visited?.labelKey).toBe('assistant.tool.unmark_region_visited');
expect(TOOL_LABELS.mark_country_visited?.labelKey).toBe('assistant.tool.mark_country_visited');
expect(STEP_LOOKS.list_bucket_list?.stepKey).toBe('assistant.step.bucketList');
const applied = { toolCallId: 'c', entity: 'atlas' as const, ids: [], dayId: null, undoUntil: null };
expect(ENTITY_LOOK.atlas.open?.(applied, null)).toEqual({ kind: 'page', path: '/atlas' });
expect(ENTITY_LOOK.atlas.onMap).toBeUndefined();
expect(CARD_LOOKS.atlasBucket.followUps?.map((followUp) => followUp.id)).toEqual(['atlasPlanWish', 'atlasAddWish']);
});
});
@@ -0,0 +1,172 @@
/**
* FE-AI-ATLAS-CHANGE-001 to FE-AI-ATLAS-CHANGE-004
*
* Atlas changes inside the change card, drawn by the atlas views: each
* country or wish a row with its flag and standing, the run headed by the
* counters it moves. Switching a row off moves the counter at once; applied,
* the counter is the exact count and the receipt opens the Atlas.
*/
import type { AssistantApplied, AssistantChangePreview, AtlasBucketCard, AtlasCountryCard } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
function preview(toolCallId: string, subject: string, tool: string, remove = false): AssistantChangePreview {
return {
toolCallId,
verbKey: remove ? 'genericRemove' : 'genericCreate',
subject,
rows: [],
dayId: null,
placeIds: [],
undoable: false,
tool,
domain: 'atlas',
};
}
function country(
code: string,
status: AtlasCountryCard['status'],
change: Pick<NonNullable<AtlasCountryCard['change']>, 'op' | 'after' | 'countries'>
): AtlasCountryCard {
return {
kind: 'atlasCountry',
code,
status,
markedBy: null,
placeCount: 0,
tripCount: 0,
trips: [],
moreTrips: 0,
places: [],
morePlaces: 0,
change: { ...change, region: null, regions: null },
};
}
const JAPAN = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
const KOREA = country('KR', null, { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
const THAILAND = country('TH', 'visited', { op: 'unmark', after: 'none', countries: { before: 23, after: 22 } });
function pending(id: string, subject: string, tool: string, card: ChangeItem['card'], remove = false): ChangeItem {
return { toolCallId: id, preview: preview(id, subject, tool, remove), applied: null, state: 'pending', card };
}
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantChangeCard items={items} />
</AssistantViewContext.Provider>
);
}
beforeEach(() => {
useAssistantStore.setState({
decide: vi.fn(async () => {}),
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } },
});
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.clearAllMocks();
});
describe('atlas changes', () => {
it('FE-AI-ATLAS-CHANGE-001: three country changes share one counter, and switching one off moves it at once', () => {
renderCard([
pending('c1', 'Japan', 'mark_country_visited', JAPAN),
pending('c2', 'South Korea', 'mark_country_visited', KOREA),
pending('c3', 'Thailand', 'unmark_country_visited', THAILAND, true),
]);
expect(screen.getByText('Countries visited')).toBeInTheDocument();
expect(screen.getByText('23')).toBeInTheDocument();
expect(screen.getByText('24')).toBeInTheDocument();
expect(screen.getByText('+1')).toBeInTheDocument();
expect(screen.getAllByText('Mark the country as visited')).toHaveLength(2);
expect(screen.getByText('Remove a visited country')).toBeInTheDocument();
expect(screen.getAllByText('Atlas')).toHaveLength(3);
// Japan was planned, Korea not on the map, Thailand leaves it.
expect(screen.getByText('Planned')).toHaveClass('line-through');
expect(screen.getAllByText('Visited')).toHaveLength(3);
expect(screen.getByText('Not on your map')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Include this change' })[2] as HTMLElement);
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-AI-ATLAS-CHANGE-002: applied, the counter is the exact count and the receipt opens the Atlas', () => {
const onOpen = vi.fn();
const applied: AssistantApplied = { toolCallId: 'c1', entity: 'atlas', ids: [], dayId: null, undoUntil: null };
const exact = country('JP', 'planned', { op: 'mark', after: 'visited', countries: { before: 23, after: 24 } });
renderCard([{ ...pending('c1', 'Japan', 'mark_country_visited', exact), applied, state: 'applied' }], { onOpen });
expect(screen.getByText('24')).toBeInTheDocument();
expect(screen.queryByText('+1')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Open Atlas' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
});
it('FE-AI-ATLAS-CHANGE-003: a wish to add shows its month, its notes and that it is on the list already', () => {
const card: AtlasBucketCard = {
kind: 'atlasBucket',
items: [
{
id: null,
name: 'Northern lights',
countryCode: null,
regionCode: null,
targetDate: '2027-02',
notes: 'Glacier hike and hot springs',
reachedOn: null,
reachedFrom: null,
},
],
more: 0,
change: { op: 'add', before: null, duplicate: true, wishes: { before: 7, after: 7 } },
};
renderCard([pending('w1', 'Northern lights', 'create_bucket_list_item', card)]);
expect(screen.getByText('Add to the bucket list')).toBeInTheDocument();
expect(screen.getByText('Feb 2027')).toBeInTheDocument();
expect(screen.getByText('Already on your list')).toBeInTheDocument();
expect(screen.getByText('Glacier hike and hot springs')).toBeInTheDocument();
expect(screen.getByText('Bucket list')).toBeInTheDocument();
expect(screen.getAllByText('7')).toHaveLength(2);
});
it('FE-AI-ATLAS-CHANGE-004: a wish change strikes through the date and the notes it takes away and shows a new name', () => {
const before = {
id: 5,
name: 'Northern lights',
countryCode: 'IS',
regionCode: null,
targetDate: '2027-02',
notes: 'Glacier hike',
reachedOn: null,
reachedFrom: null,
};
const card: AtlasBucketCard = {
kind: 'atlasBucket',
items: [{ ...before, name: 'Aurora', targetDate: null, notes: null }],
more: 0,
change: { op: 'update', before, duplicate: false, wishes: null },
};
renderCard([pending('w2', 'Aurora', 'update_bucket_list_item', card)]);
expect(screen.getByText('Feb 2027')).toHaveClass('line-through');
expect(screen.getByText('Glacier hike')).toHaveClass('line-through');
expect(screen.getAllByText('Removed')).toHaveLength(2);
expect(screen.getByText('Northern lights')).toHaveClass('line-through');
expect(screen.getAllByText('Aurora').length).toBeGreaterThan(0);
// No counter heads a wish change: the size of the list does not move.
expect(screen.queryByText('Bucket list')).toBeNull();
});
});
@@ -0,0 +1,163 @@
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
import { Briefcase, MapPin, Radio } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import CountryFlag from '../../../shared/CountryFlag';
import type { ChangeItem } from '../../assistantParts';
import type { CardEntry } from '../cardTypes';
import { CardFacts } from '../parts/CardFacts';
import CardFrame from '../parts/CardFrame';
import { CardOpenButton } from '../parts/CardOpen';
import { CardMore } from '../parts/CardRows';
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
import { ATLAS_PAGE } from './atlasPage';
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
import { useAtlasCard } from './useAtlasCard';
/**
* One country of the atlas the way the Atlas page's country sheet shows it:
* the flag and the name with its standing, the continent and how many trips
* and places put it there, the latest trips with their dates, the places as
* badges, and whether a recording ticked it. In a change card the same
* country is the row: its flag as the lead, its standing from now to after.
*/
type Translate = ReturnType<typeof useTranslation>['t'];
/** A trip's dates as "3 Apr 2024 to 14 Apr 2024", one day alone, or nothing. */
function tripDates(start: string | null, end: string | null, locale: string, t: Translate): string | null {
if (start && end && start !== end) {
return t('assistant.card.atlas.dates', { from: shortDay(start, locale), to: shortDay(end, locale) });
}
const day = start ?? end;
return day ? shortDay(day, locale) : null;
}
function CountrySection({ card }: { card: CountryCard }) {
const { t } = useTranslation();
const { countryName, locale } = useAtlasCard();
const status = STATUS_LOOK[card.status ?? 'none'];
const trips = card.tripCount ?? 0;
const places = card.placeCount ?? 0;
return (
<div>
<div className="flex items-start gap-2.5">
<FlagTile code={card.code} />
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<bdi className="min-w-0 flex-1 break-words text-body font-semibold leading-tight text-content">
{countryName(card.code)}
</bdi>
<SoftPill tone={status.tone} icon={<status.Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t(status.labelKey)}
</SoftPill>
</div>
<CardFacts>
<span className="me-0.5 text-caption text-content-faint">
{t(CONTINENT_LABEL[continentOf(card.code)].labelKey)}
</span>
{trips > 0 && (
<WhiteBadge icon={<Briefcase size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.atlas.trips', { count: trips })}
</WhiteBadge>
)}
{places > 0 && (
<WhiteBadge icon={<MapPin size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.atlas.places', { count: places })}
</WhiteBadge>
)}
</CardFacts>
</div>
</div>
{card.trips.length > 0 && (
<ul className="m-0 mt-2 flex list-none flex-col gap-1 p-0">
{card.trips.map((trip) => (
<li key={trip.id} className="flex items-baseline gap-2 text-caption">
<bdi title={trip.title} className="min-w-0 flex-1 truncate text-content-secondary">
{trip.title}
</bdi>
<span className="flex-none tabular-nums text-content-faint">
{tripDates(trip.startDate, trip.endDate, locale, t)}
</span>
</li>
))}
</ul>
)}
<CardMore count={card.moreTrips} target={ATLAS_PAGE} />
{card.places.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{card.places.map((place) => (
<WhiteBadge key={place} className="max-w-full">
<bdi className="truncate">{place}</bdi>
</WhiteBadge>
))}
<CardMore count={card.morePlaces} target={ATLAS_PAGE} />
</div>
)}
{card.markedBy === 'dawarich' && (
<p className="m-0 mt-2 flex items-center gap-1 text-caption text-content-faint">
<Radio size={12} aria-hidden="true" className="flex-none" />
{t('assistant.card.atlas.fromRecordings')}
</p>
)}
</div>
);
}
/** The countries of an answer, one under the other in one frame. */
export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const { t } = useTranslation();
const countries = cards.flatMap((entry) => (entry.card.kind === 'atlasCountry' ? [entry] : []));
if (countries.length === 0) return null;
return (
<CardFrame label={t('assistant.domain.atlas')}>
{countries.map((entry, index) => (
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
</div>
))}
<div className="mt-1 flex justify-end">
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
</div>
</CardFrame>
);
}
/** A country change's lead: the flag with what the change does as a dot on its corner. */
export function AtlasCountryLead({ card, item }: { card: CountryCard; item: ChangeItem }) {
return <ChangeFlag code={card.code} item={item} />;
}
/**
* What a country change does: the country's standing from now to after (just
* the new one for a country not on the map yet) and the trips that keep it
* there; for a region, its country and the region's own standing.
*/
export function AtlasCountryFacts({ card }: { card: CountryCard; item: ChangeItem }) {
const { t } = useTranslation();
const { countryName } = useAtlasCard();
const change = card.change;
if (!change) return null;
if (change.region) {
return (
<>
<WhiteBadge icon={<CountryFlag code={card.code} size={10} />}>
<bdi>{countryName(card.code)}</bdi>
</WhiteBadge>
<StatusFact before={change.region.before} after={change.region.after} />
</>
);
}
const trips = card.tripCount ?? 0;
return (
<>
<StatusFact before={card.status} after={change.after} />
{trips > 0 && (
<WhiteBadge icon={<Briefcase size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.atlas.trips', { count: trips })}
</WhiteBadge>
)}
</>
);
}
@@ -0,0 +1,58 @@
import type { AssistantChangeVerbKey, AtlasStatusAfter } from '@trek/shared';
import { Star } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import CountryFlag from '../../../shared/CountryFlag';
import type { ChangeItem } from '../../assistantParts';
import { verbLookOf } from '../cardLooks';
import { FactChange } from '../parts/CardFacts';
import KindTile from '../parts/KindTile';
import { STATUS_LOOK } from './atlasCardModel';
/**
* The pieces the atlas cards share: a country's flag on a tile (a star for a
* wish without a country), with the change's verb as a dot on its corner, and
* a country's or a region's standing as a pill, from what it was to what it
* will be.
*/
export function FlagTile({
code,
verbKey = null,
dimmed = false,
}: {
code: string | null;
verbKey?: AssistantChangeVerbKey | null;
dimmed?: boolean;
}) {
const look = verbKey ? verbLookOf(verbKey) : null;
return (
<span
aria-hidden="true"
className={`relative flex h-7 w-7 flex-none items-center justify-center rounded-md ${
code ? 'bg-surface-secondary' : 'bg-warning-soft text-warning'
} ${dimmed ? 'opacity-60' : ''}`}
>
{code ? <CountryFlag code={code} size={18} /> : <Star size={14} strokeWidth={2.1} />}
{look && (
<span className="absolute -bottom-0.5 -end-0.5">
<KindTile icon={look.Icon} tone={look.tone} size="xs" />
</span>
)}
</span>
);
}
/** A change row's lead: the flag (a star without a country) with the change's verb, faded once it will not land. */
export function ChangeFlag({ code, item }: { code: string | null; item: ChangeItem }) {
const settled = item.state === 'discarded' || item.state === 'expired';
return <FlagTile code={code} verbKey={item.preview?.verbKey ?? null} dimmed={settled} />;
}
/** A standing as a pill in its tone; the old one struck through in front when it changes. */
export function StatusFact({ before, after }: { before: AtlasStatusAfter | null; after: AtlasStatusAfter }) {
const { t } = useTranslation();
const look = STATUS_LOOK[after];
const was = before && before !== after ? t(STATUS_LOOK[before].labelKey) : null;
return <FactChange icon={look.Icon} tone={look.tone} before={was} after={t(look.labelKey)} />;
}
@@ -0,0 +1,147 @@
import type { AssistantMessage, AtlasPassportCard } from '@trek/shared';
import { CalendarClock, Flame, Globe2, Plane } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import CountryFlag from '../../../shared/CountryFlag';
import { Tooltip } from '../../../shared/Tooltip';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { CardOpenButton } from '../parts/CardOpen';
import { CardMore } from '../parts/CardRows';
import { useCardFormat } from '../parts/useCardFormat';
import { CONTINENT_LABEL, STATUS_LOOK } from './atlasCardModel';
import { ATLAS_PAGE } from './atlasPage';
import { useAtlasCard } from './useAtlasCard';
/**
* The user's atlas as the Atlas page shows it: the visited countries as the
* big number with the planned ones beside it, the strip of travel figures
* (trips, days, cities, the distance flown), the flags latest first with the
* planned ones faded, the continents, the next trip and the streak. A user
* without trips gets the number and the flags.
*/
/** One figure of the strip, the way the Atlas page's stats read: the number big, its name small under it. */
function Figure({ value, label }: { value: string; label: string }) {
return (
<div className="flex min-w-0 flex-col-reverse items-center px-1">
<dt className="mt-1 max-w-full truncate text-caption font-semibold uppercase tracking-wide text-content-faint">
{label}
</dt>
<dd className="m-0 text-body font-black tabular-nums leading-none text-content">{value}</dd>
</div>
);
}
function Passport({ card }: { card: AtlasPassportCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const { countryName, limits } = useAtlasCard();
const { countries, totals } = card;
const flags = card.flags.slice(0, limits.flags);
const moreFlags = card.flags.length - flags.length + card.moreFlags;
const hasTrips = totals.trips > 0;
const figures = [
{ key: 'trips', value: format.count(totals.trips), label: t('atlas.trips', { count: totals.trips }) },
{ key: 'days', value: format.count(totals.days), label: t('atlas.days', { count: totals.days }) },
{ key: 'cities', value: format.count(totals.cities), label: t('atlas.cities', { count: totals.cities }) },
...(totals.flownKm === null
? []
: [{ key: 'flown', value: format.distance(totals.flownKm), label: t('assistant.card.atlas.factFlown') }]),
];
const continents = card.continents.filter((continent) => continent.visited > 0);
const next = card.nextTrip;
const nextLine =
next && next.daysUntil !== null
? next.daysUntil === 0
? t('assistant.card.atlas.nextTripToday', { title: next.title })
: t('assistant.card.atlas.nextTrip', { title: next.title, count: next.daysUntil })
: null;
const streak = card.streakYears !== null && card.streakYears >= 2 ? card.streakYears : null;
return (
<CardFrame label={t('assistant.card.atlas.passportTitle')}>
<CardHeader
icon={Globe2}
tone="info"
title={t('assistant.card.atlas.passportTitle')}
actions={<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />}
/>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-baseline gap-1.5">
<span className="text-title font-black tabular-nums leading-none text-content">
{format.count(countries.visited)}
</span>
<span className="text-caption font-medium text-content-muted">
{t('atlas.countries', { count: countries.visited })}
</span>
</span>
{countries.planned > 0 && (
<SoftPill tone="info" icon={<CalendarClock size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.atlas.planned', { count: countries.planned })}
</SoftPill>
)}
</div>
{hasTrips && (
<dl
className={`m-0 mt-2.5 grid gap-1 rounded-lg bg-surface-secondary px-1 py-2 ${
figures.length === 4 ? 'grid-cols-4' : 'grid-cols-3'
}`}
>
{figures.map((figure) => (
<Figure key={figure.key} value={figure.value} label={figure.label} />
))}
</dl>
)}
{flags.length > 0 && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{flags.map((flag) => {
const label = `${countryName(flag.code)}, ${t(STATUS_LOOK[flag.status].labelKey)}`;
return (
<Tooltip key={flag.code} label={label} placement="top">
<span role="img" aria-label={label} className={flag.status === 'planned' ? 'opacity-60' : ''}>
<CountryFlag code={flag.code} size={18} />
</span>
</Tooltip>
);
})}
<CardMore count={moreFlags} target={ATLAS_PAGE} />
</div>
)}
{continents.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{continents.map((continent) => (
<WhiteBadge key={continent.name}>
{t(CONTINENT_LABEL[continent.name].labelKey)}
<span className="font-semibold text-content">{format.count(continent.visited)}</span>
</WhiteBadge>
))}
</div>
)}
{(nextLine || streak !== null) && (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-content-faint">
{nextLine && (
<span className="flex min-w-0 items-center gap-1">
<Plane size={12} aria-hidden="true" className="flex-none rtl:-scale-x-100" />
<bdi className="break-words">{nextLine}</bdi>
</span>
)}
{streak !== null && (
<span className="flex items-center gap-1">
<Flame size={12} aria-hidden="true" className="flex-none" />
{t('assistant.card.atlas.streak', { count: streak })}
</span>
)}
</div>
)}
</CardFrame>
);
}
/** The passport of an answer; a second read in the same answer shows the newer figures only. */
export default function AtlasPassportBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const passports = cards.flatMap((entry) => (entry.card.kind === 'atlasPassport' ? [entry.card] : []));
const latest = passports[passports.length - 1];
return latest ? <Passport card={latest} /> : null;
}
@@ -0,0 +1,74 @@
import { ArrowRight, Check, Globe2, MapPinned, Star } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { useCardFormat } from '../parts/useCardFormat';
import { signedDelta, tallyLines, type TallyLine } from './atlasCardModel';
import { useAtlasCard } from './useAtlasCard';
/**
* The counters over a run of atlas changes, the way the Atlas page puts its
* numbers first: countries visited, the regions of a country, the bucket list,
* each from what it is to what it will be. Ticking a row off moves the number
* at once; while the changes are applied the new number shimmers, once they
* are, it is the exact count the writes answered.
*/
const ICONS = { countries: Globe2, regions: MapPinned, wishes: Star } as const;
function TallyRow({ line }: { line: TallyLine }) {
const { t } = useTranslation();
const { countryName, locale } = useAtlasCard();
const Icon = ICONS[line.what];
const label =
line.what === 'countries'
? t('assistant.card.atlas.tallyCountries')
: line.what === 'regions'
? t('assistant.card.atlas.tallyRegions', { country: countryName(line.country ?? '') })
: t('assistant.card.atlas.tallyWishes');
const delta = line.after - line.before;
const format = useCardFormat();
return (
<div className="flex items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5">
<Icon size={14} strokeWidth={2.1} aria-hidden="true" className="flex-none text-content-muted" />
<span className="min-w-0 flex-1 truncate text-caption text-content-secondary">{label}</span>
<span className="flex-none text-caption tabular-nums text-content-faint">{format.count(line.before)}</span>
<ArrowRight size={12} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
<span
className={`flex-none text-title font-semibold tabular-nums leading-none text-content ${
line.phase === 'applying' ? 'trek-assistant-shimmer' : ''
}`}
>
{format.count(line.after)}
</span>
{line.phase === 'applied' ? (
<Check size={14} strokeWidth={2.4} aria-hidden="true" className="flex-none text-success" />
) : (
delta !== 0 && <SoftPill tone={delta > 0 ? 'success' : 'danger'}>{signedDelta(delta, locale)}</SoftPill>
)}
</div>
);
}
/** Heads a run of atlas changes with the counters they move; nothing under the run. */
export default function AtlasTallyGroup({
items,
excluded,
place,
}: {
items: readonly ChangeItem[];
excluded: ReadonlySet<string>;
place: 'head' | 'foot';
}) {
if (place !== 'head') return null;
const lines = tallyLines(items, excluded);
if (lines.length === 0) return null;
return (
<div className="mb-1 flex flex-col gap-1">
{lines.map((line) => (
<TallyRow key={line.key} line={line} />
))}
</div>
);
}
@@ -0,0 +1,142 @@
/**
* FE-AI-ATLAS-MODEL-001 to FE-AI-ATLAS-MODEL-006
*
* The pure side of the atlas cards: the counters a run of atlas changes moves
* as rows are ticked off, applied or discarded, a wish's month, the flags and
* tiles that fit, and the continent a country counts for.
*/
import type { AtlasBucketCard, AtlasCountryCard, TrekCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { ChangeItem, ChangeState } from '../../assistantParts';
import { continentOf, densityLimits, shortDay, signedDelta, tallyLines, wishMonth } from './atlasCardModel';
function country(code: string, before: number, after: number | null, regionCode?: string): AtlasCountryCard {
return {
kind: 'atlasCountry',
code,
status: null,
markedBy: null,
placeCount: null,
tripCount: null,
trips: [],
moreTrips: 0,
places: [],
morePlaces: 0,
change: {
op: 'mark',
region: regionCode ? { code: regionCode, name: 'North', before: null, after: 'visited' } : null,
after: after === null ? 'unknown' : 'visited',
countries: { before, after },
regions: regionCode ? { before: 1, after: 2 } : null,
},
};
}
const wish: AtlasBucketCard = {
kind: 'atlasBucket',
items: [
{
id: null,
name: 'Patagonia',
countryCode: 'AR',
regionCode: null,
targetDate: null,
notes: null,
reachedOn: null,
reachedFrom: null,
},
],
more: 0,
change: { op: 'add', before: null, duplicate: false, wishes: { before: 7, after: 8 } },
};
function item(id: string, card: TrekCard, state: ChangeState = 'pending'): ChangeItem {
return { toolCallId: id, preview: null, applied: null, state, card };
}
const NONE: ReadonlySet<string> = new Set();
describe('atlasCardModel', () => {
it('FE-AI-ATLAS-MODEL-001: waiting changes move the counter by what each ticked row adds, an unknown one by nothing', () => {
const items = [
item('a', country('JP', 23, 24)),
item('b', country('KR', 23, 24)),
item('c', country('TH', 23, null)),
];
expect(tallyLines(items, NONE)).toEqual([
{ key: 'countries', what: 'countries', country: null, before: 23, after: 25, phase: 'pending' },
]);
expect(tallyLines(items, new Set(['b']))[0]).toMatchObject({ before: 23, after: 24 });
expect(tallyLines([item('w', wish)], NONE)).toEqual([
{ key: 'wishes', what: 'wishes', country: null, before: 7, after: 8, phase: 'pending' },
]);
});
it('FE-AI-ATLAS-MODEL-002: a region change adds the regions of its country; discarded or expired changes count for nothing', () => {
const lines = tallyLines([item('a', country('JP', 23, 24, 'JP-01'))], NONE);
expect(lines.map((line) => line.key)).toEqual(['countries', 'regions-JP']);
expect(lines[1]).toMatchObject({ what: 'regions', country: 'JP', before: 1, after: 2 });
const settled = [item('a', country('JP', 23, 24), 'discarded'), item('b', country('KR', 23, 24), 'expired')];
expect(tallyLines(settled, NONE)).toEqual([]);
});
it('FE-AI-ATLAS-MODEL-003: while applying the counter shimmers, once applied it shows the exact count the write answered', () => {
const applying = [item('a', country('JP', 23, 24), 'applying'), item('b', country('KR', 23, 24), 'applying')];
expect(tallyLines(applying, NONE)[0]).toMatchObject({ after: 25, phase: 'applying' });
const applied = [item('a', country('JP', 23, 24), 'applied'), item('b', country('KR', 24, 24), 'applied')];
expect(tallyLines(applied, NONE)[0]).toMatchObject({ before: 23, after: 24, phase: 'applied' });
});
it('FE-AI-ATLAS-MODEL-004: a wish month reads as month and year, a year alone as the year', () => {
expect(wishMonth('2027', 'en')).toBe('2027');
expect(wishMonth('2027-05', 'en')).toBe('May 2027');
expect(wishMonth('2027-05-14', 'de')).toBe('Mai 2027');
expect(wishMonth(null, 'en')).toBeNull();
expect(shortDay('2024-04-03', 'en')).toBe('Apr 3, 2024');
expect(shortDay('soon', 'en')).toBe('soon');
});
it('FE-AI-ATLAS-MODEL-005: more flags and tiles fit a roomy chat; a country counts for its continent', () => {
expect(densityLimits(false)).toEqual({ flags: 16, wishes: 6 });
expect(densityLimits(true)).toEqual({ flags: 24, wishes: 9 });
expect(continentOf('JP')).toBe('Asia');
expect(continentOf('ZZ')).toBe('Other');
expect(signedDelta(2, 'en')).toBe('+2');
expect(signedDelta(-1, 'en')).toBe('-1');
expect(signedDelta(0, 'en')).toBe('');
});
it('FE-AI-ATLAS-MODEL-006: a country or a region counts once however many waiting changes touch it', () => {
// Japan and Tokyo in one turn: one more country, one more region.
const japanAndTokyo = tallyLines(
[item('a', country('JP', 23, 24)), item('b', country('JP', 23, 24, 'JP-13'))],
NONE
);
expect(japanAndTokyo).toEqual([
{ key: 'countries', what: 'countries', country: null, before: 23, after: 24, phase: 'pending' },
{ key: 'regions-JP', what: 'regions', country: 'JP', before: 1, after: 2, phase: 'pending' },
]);
// Two regions of a country not visited yet: one more country, two more regions.
const twoRegions = tallyLines(
[item('a', country('KR', 23, 24, 'KR-11')), item('b', country('KR', 23, 24, 'KR-26'))],
NONE
);
expect(twoRegions.map((line) => [line.key, line.after])).toEqual([
['countries', 24],
['regions-KR', 3],
]);
// The same country twice, the same region twice: each counts once.
const twice = [item('a', country('JP', 23, 24)), item('b', country('JP', 23, 24))];
expect(tallyLines(twice, NONE)[0]?.after).toBe(24);
const tokyoTwice = [item('a', country('JP', 23, 24, 'JP-13')), item('b', country('JP', 23, 24, 'JP-13'))];
const sameRegion = tallyLines(tokyoTwice, NONE);
expect(sameRegion.map((line) => line.after)).toEqual([24, 2]);
// Switching one of the two off keeps the other's move.
expect(tallyLines(twice, new Set(['a']))[0]?.after).toBe(24);
// Once Japan landed, a waiting Tokyo adds no country on top of the exact count.
const tokyo = item('b', country('JP', 23, 24, 'JP-13'));
const landed = tallyLines([item('a', country('JP', 23, 24), 'applied'), tokyo], NONE);
expect(landed[0]).toMatchObject({ key: 'countries', after: 24, phase: 'applying' });
});
});
@@ -0,0 +1,203 @@
import {
ATLAS_CARD_CONTINENTS,
type AtlasCardContinent,
type AtlasStatusAfter,
type AtlasTally,
CONTINENT_MAP,
type TrekCard,
} from '@trek/shared';
import { Briefcase, CalendarClock, Check, CircleDashed, Lightbulb, type LucideIcon } from 'lucide-react';
import type { PillTone } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
/**
* The pure side of the atlas cards: how a status and a continent are named,
* how many flags and wish tiles fit the chat, a wish's month, and the counters
* a run of atlas changes moves (countries visited, regions of a country, the
* bucket list) as the user ticks rows on and off, applies or discards them.
*/
/** How a country's or a region's standing reads. A table, so each word is named where it is read. */
export const STATUS_LOOK: Readonly<Record<AtlasStatusAfter, { Icon: LucideIcon; tone: PillTone; labelKey: string }>> =
{
visited: { Icon: Check, tone: 'success', labelKey: 'assistant.card.atlas.status.visited' },
planned: { Icon: CalendarClock, tone: 'info', labelKey: 'assistant.card.atlas.status.planned' },
idea: { Icon: Lightbulb, tone: 'neutral', labelKey: 'assistant.card.atlas.status.idea' },
none: { Icon: CircleDashed, tone: 'neutral', labelKey: 'assistant.card.atlas.status.none' },
unknown: { Icon: Briefcase, tone: 'info', labelKey: 'assistant.card.atlas.status.unknown' },
};
/** The continents in the Atlas page's own words. */
export const CONTINENT_LABEL: Readonly<Record<AtlasCardContinent, { labelKey: string }>> = {
Europe: { labelKey: 'atlas.europe' },
Asia: { labelKey: 'atlas.asia' },
'North America': { labelKey: 'atlas.northAmerica' },
'South America': { labelKey: 'atlas.southAmerica' },
Africa: { labelKey: 'atlas.africa' },
Oceania: { labelKey: 'atlas.oceania' },
Antarctica: { labelKey: 'atlas.antarctica' },
Other: { labelKey: 'atlas.other' },
};
/** The continent a country lies on, as the Atlas counts it. */
export function continentOf(code: string): AtlasCardContinent {
const name = CONTINENT_MAP[code];
return (ATLAS_CARD_CONTINENTS as readonly string[]).includes(name ?? '') ? (name as AtlasCardContinent) : 'Other';
}
/** How many flags and wish tiles a card shows: more where the chat has room. */
export function densityLimits(roomy: boolean): { flags: number; wishes: number } {
return roomy ? { flags: 24, wishes: 9 } : { flags: 16, wishes: 6 };
}
/** "May 2027" for a month or a day, the year alone for a year; null for none. */
export function wishMonth(targetDate: string | null, locale: string): string | null {
if (!targetDate) return null;
const [year, month] = targetDate.split('-').map(Number);
if (!year) return null;
if (!month) return String(year);
try {
return new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' }).format(
new Date(Date.UTC(year, month - 1, 1))
);
} catch {
return targetDate;
}
}
/** "3 Apr 2024" from `YYYY-MM-DD`; anything else as it came. */
export function shortDay(isoDay: string, locale: string): string {
const [year, month, day] = isoDay.split('-').map(Number);
if (!year || !month || !day) return isoDay;
try {
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(
new Date(Date.UTC(year, month - 1, day))
);
} catch {
return isoDay;
}
}
/** Where a counter stands in a run of changes: waiting, being applied, applied. */
type TallyPhase = 'pending' | 'applying' | 'applied';
/** One counter a run of atlas changes moves. */
export interface TallyLine {
key: string;
what: 'countries' | 'regions' | 'wishes';
/** The country of a regions counter. */
country: string | null;
before: number;
after: number;
phase: TallyPhase;
}
/** The counter one change moves, which one, and what it counts on it (a country, a region; null: the change itself). */
interface Counted {
key: string;
what: TallyLine['what'];
country: string | null;
subject: string | null;
tally: AtlasTally;
}
/** The counters one card moves. */
function countedOf(card: TrekCard): Counted[] {
if (card.kind === 'atlasCountry' && card.change) {
const { countries, regions, region } = card.change;
const counted: Counted[] = [];
if (countries) {
counted.push({ key: 'countries', what: 'countries', country: null, subject: card.code, tally: countries });
}
if (regions && region) {
const key = `regions-${card.code}`;
counted.push({ key, what: 'regions', country: card.code, subject: region.code, tally: regions });
}
return counted;
}
if (card.kind === 'atlasBucket' && card.change?.wishes) {
return [{ key: 'wishes', what: 'wishes', country: null, subject: null, tally: card.change.wishes }];
}
return [];
}
/** A change that will not land: discarded, expired or failed. */
function settled(item: ChangeItem): boolean {
return item.state === 'discarded' || item.state === 'expired' || item.state === 'failed';
}
/** One counter while a run is read: the exact count a write answered, what each subject still moves, what landed. */
interface Building {
line: TallyLine;
exact: number | null;
moves: Map<string, number>;
landed: Set<string>;
}
/** What the waiting moves add up to: each country or region once, by one at most either way, none that landed. */
function movedBy({ moves, landed }: Building): number {
let moved = 0;
for (const [subject, delta] of moves) {
if (!landed.has(subject)) moved += Math.max(-1, Math.min(1, delta));
}
return moved;
}
/**
* The counters a run of atlas changes moves. Waiting, a counter starts where
* the first change found it and moves by what the changes still ticked on do
* (a change whose outcome the trips decide moves it by nothing until it is
* applied). Every change was predicted from the same reading, so a country or
* a region counts once: marking Japan and Tokyo, or Japan twice, is one more
* country. Applied, it shows the last exact count a write answered, and a
* country or region that landed moves it no further. A counter whose changes
* were all discarded or expired is left out.
*/
export function tallyLines(items: readonly ChangeItem[], excluded: ReadonlySet<string>): TallyLine[] {
const built = new Map<string, Building>();
for (const item of items) {
if (!item.card || settled(item)) continue;
for (const counted of countedOf(item.card)) {
const entry = built.get(counted.key) ?? {
line: {
key: counted.key,
what: counted.what,
country: counted.country,
before: counted.tally.before,
after: counted.tally.before,
phase: 'pending' as TallyPhase,
},
exact: null,
moves: new Map<string, number>(),
landed: new Set<string>(),
};
const subject = counted.subject ?? item.toolCallId;
if (item.state === 'applied') {
if (counted.tally.after !== null) entry.exact = counted.tally.after;
entry.landed.add(subject);
if (entry.line.phase !== 'applying') entry.line.phase = 'applied';
} else {
if (item.state === 'applying' || entry.line.phase === 'applied') entry.line.phase = 'applying';
if (!excluded.has(item.toolCallId) && counted.tally.after !== null) {
entry.moves.set(subject, (entry.moves.get(subject) ?? 0) + counted.tally.after - counted.tally.before);
}
}
built.set(counted.key, entry);
}
}
return [...built.values()].map((entry) => ({
...entry.line,
after: Math.max(0, (entry.exact ?? entry.line.before) + movedBy(entry)),
}));
}
/** "+2" or "-1" for what a counter moved by; empty when it did not move. */
export function signedDelta(delta: number, locale: string): string {
if (delta === 0) return '';
try {
return new Intl.NumberFormat(locale, { signDisplay: 'exceptZero' }).format(delta);
} catch {
return delta > 0 ? `+${delta}` : String(delta);
}
}
@@ -1,20 +1,58 @@
import { Globe2 } from 'lucide-react';
import type { PackLook } from '../cardTypes';
import { ATLAS_PAGE } from './atlasPage';
/**
* What the atlas brings to the chat's pure look: the verbs, tool names and
* steps it adds, what an applied change of its own opens, and the look of each
* of its kinds. Empty until the atlas cards fill it. A look stays pure, icons
* and literals only: the chat's main chunk reads every look, so whatever a look
* imports ships with it.
* What the atlas brings to the chat's pure look: the names of its tools and
* reading steps, what an applied atlas change opens (the Atlas page: a
* country, a region or a wish has no place on the trip's map), and what to
* ask after each of its cards. A look stays pure, icons and literals only:
* the chat's main chunk reads every look, so whatever a look imports ships
* with it. `mark_country_visited` keeps its core label.
*/
export const ATLAS_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
tools: {
unmark_country_visited: { labelKey: 'assistant.tool.unmark_country_visited' },
mark_region_visited: { labelKey: 'assistant.tool.mark_region_visited' },
unmark_region_visited: { labelKey: 'assistant.tool.unmark_region_visited' },
create_bucket_list_item: { labelKey: 'assistant.tool.create_bucket_list_item' },
update_bucket_list_item: { labelKey: 'assistant.tool.update_bucket_list_item' },
delete_bucket_list_item: { labelKey: 'assistant.tool.delete_bucket_list_item' },
},
steps: {
get_atlas_stats: { stepKey: 'assistant.step.atlas' },
get_country_atlas_places: { stepKey: 'assistant.step.atlasCountry' },
list_visited_regions: { stepKey: 'assistant.step.regions' },
locate_atlas_region: { stepKey: 'assistant.step.locateRegion' },
list_bucket_list: { stepKey: 'assistant.step.bucketList' },
},
entities: {
atlas: {
Icon: Globe2,
openLabelKey: 'assistant.card.atlas.open',
open: () => ATLAS_PAGE,
},
},
cards: {
atlasPassport: {},
atlasCountry: {},
atlasBucket: {},
atlasPassport: {
followUps: [
{ id: 'atlasWishes', labelKey: 'assistant.followUp.atlasWishes' },
{ id: 'atlasLeast', labelKey: 'assistant.followUp.atlasLeast' },
],
},
atlasCountry: {
followUps: [
{ id: 'atlasMark', labelKey: 'assistant.followUp.atlasMark' },
{ id: 'atlasWish', labelKey: 'assistant.followUp.atlasWish' },
],
},
atlasBucket: {
followUps: [
{ id: 'atlasPlanWish', labelKey: 'assistant.followUp.atlasPlanWish' },
{ id: 'atlasAddWish', labelKey: 'assistant.followUp.atlasAddWish' },
],
},
},
} satisfies PackLook<'atlasPassport' | 'atlasCountry' | 'atlasBucket'>;
@@ -0,0 +1,8 @@
import type { EntityTarget } from '../cardTypes';
/**
* Where every atlas card leads: the Atlas page. A file of its own, so the
* pure look names it without pulling the card model (continents, icons) into
* the chat's main chunk.
*/
export const ATLAS_PAGE: EntityTarget = { kind: 'page', path: '/atlas' };
@@ -1,14 +1,31 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import AtlasBucketBlock, { AtlasBucketBody, AtlasBucketFacts, AtlasBucketLead } from './AtlasBucketCard';
import AtlasCountryBlock, { AtlasCountryFacts, AtlasCountryLead } from './AtlasCountryCard';
import AtlasPassportBlock from './AtlasPassportCard';
import AtlasTallyGroup from './AtlasTally';
/**
* How the kinds of the atlas draw in the chat; each one draws nothing until its
* cards are built. This file only maps each kind to its view: the components
* live in sibling files, since a file that exports anything besides components
* breaks fast refresh, and lint:warnings holds
* How the kinds of the atlas draw in the chat: the passport and the countries
* of an answer, the bucket list as wish tiles, and inside a change card each
* country, region or wish as a row with its flag, headed by the counters the
* run of changes moves. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const ATLAS_VIEWS = {
atlasPassport: PENDING_VIEW,
atlasCountry: PENDING_VIEW,
atlasBucket: PENDING_VIEW,
atlasPassport: { Block: AtlasPassportBlock },
atlasCountry: {
Block: AtlasCountryBlock,
Lead: AtlasCountryLead,
Facts: AtlasCountryFacts,
Group: AtlasTallyGroup,
},
atlasBucket: {
Block: AtlasBucketBlock,
Lead: AtlasBucketLead,
Facts: AtlasBucketFacts,
Body: AtlasBucketBody,
Group: AtlasTallyGroup,
},
} satisfies { [K in 'atlasPassport' | 'atlasCountry' | 'atlasBucket']: CardView<K> };
@@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { countryNamer } from '../../../../utils/countryName';
import { useAssistantView } from '../../assistantViewContext';
import { densityLimits } from './atlasCardModel';
/**
* What every atlas card reads from where it is drawn: country names in the
* reader's language, the locale for dates and numbers, and how many flags and
* wish tiles fit. The phone always draws the compact card, a narrow window too.
*/
export function useAtlasCard() {
const { language, locale } = useTranslation();
const { variant, density } = useAssistantView();
const roomy = variant !== 'fullscreen' && density === 'roomy';
const countryName = useMemo(() => countryNamer(language), [language]);
return { countryName, locale, roomy, limits: densityLimits(roomy) };
}
+21
View File
@@ -0,0 +1,21 @@
// FE-UTIL-COUNTRYNAME-001 to FE-UTIL-COUNTRYNAME-002
import { describe, expect, it } from 'vitest';
import { countryNamer } from './countryName';
const countryName = (code: string, language: string) => countryNamer(language)(code);
describe('countryName', () => {
it('FE-UTIL-COUNTRYNAME-001: a code reads as the country in the reader language, whatever its case', () => {
expect(countryName('JP', 'en')).toBe('Japan');
expect(countryName('fr', 'de')).toBe('Frankreich');
const name = countryNamer('es');
expect(name('DE')).toBe('Alemania');
});
it('FE-UTIL-COUNTRYNAME-002: something that is no country code stays as it came', () => {
expect(countryName('JPN', 'en')).toBe('JPN');
expect(countryName('', 'en')).toBe('');
expect(countryName('JP', 'not-a-language')).toBe('Japan');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { getIntlLanguage } from '@trek/shared';
/**
* Country names in the reader's language, from ISO alpha-2 codes. A namer is
* built once per language (the caller keeps it, a component in a memo) and
* answers the code itself where the runtime knows no name or the code is
* none, so a card never shows an empty label.
*/
export function countryNamer(language: string): (code: string) => string {
let names: Intl.DisplayNames | null = null;
try {
names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' });
} catch {
names = null;
}
return (code) => {
const upper = code.toUpperCase();
if (!names || !/^[A-Z]{2}$/.test(upper)) return code;
try {
return names.of(upper) ?? code;
} catch {
return code;
}
};
}