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