fix(ci): typed card points, the managed take route, and the failing card tests and ratchets

This commit is contained in:
Maurice
2026-10-22 16:21:20 +02:00
parent db023f4c4d
commit 162f2996d2
77 changed files with 293 additions and 244 deletions
@@ -28,7 +28,11 @@ vi.mock('./cardLooks', async (importOriginal) => {
params: { tour: String(applied.ids[0]) },
}),
},
budget: { ...actual.ENTITY_LOOK.budget, open: () => ({ kind: 'tab', tab: 'finanzplan' }) },
budget: {
...actual.ENTITY_LOOK.budget,
openLabelKey: undefined,
open: () => ({ kind: 'tab', tab: 'finanzplan' }),
},
reservation: {
...actual.ENTITY_LOOK.reservation,
openLabelKey: undefined,
@@ -8,7 +8,7 @@
* the one it replaces, the facts an update changes, the whole split with
* paid and highlighted parts, what a run of new expenses adds up to).
*/
import type { AssistantMessage, TrekCard } from '@trek/shared';
import type { TrekCard } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -22,21 +22,17 @@ import { ExpenseAside, ExpenseBody, ExpenseFacts, ExpensesTogether } from './Exp
import ExpensesCard from './ExpensesCard';
const AUTH = useAuthStore.getState();
const MESSAGE: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [],
};
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en');
// Intl puts a no-break space before the symbol, and text and label queries read it as a plain one.
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en').replace(/[\u00a0\u202f]/g, ' ');
// An accessible name is matched as it stands, so it keeps the no-break space.
const moneyName = (amount: number) => formatMoney(amount, 'EUR', 'en');
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 answer = (card: TrekCard) => <ExpensesCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />;
const answer = (card: TrekCard) => <ExpensesCard cards={[{ toolCallId: 'r1', card }]} />;
beforeEach(() => {
useAuthStore.setState({ user: buildUser({ id: ANA.id }) });
@@ -106,7 +102,7 @@ describe('the expense list', () => {
inView(answer(expenses([expense({ id: null, name: 'Gelato' })])), { onOpen: vi.fn() });
expect(screen.getByText('Gelato')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Gelato/ })).toBeNull();
const { container } = render(<ExpensesCard cards={[]} message={MESSAGE} />);
const { container } = render(<ExpensesCard cards={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -162,14 +158,14 @@ describe('an expense in a change row', () => {
expect(screen.getByLabelText(`You: ${money(28)}`)).toBeInTheDocument();
expect(screen.getByLabelText(`Ben: ${money(28)}, Paid`).firstElementChild).toHaveClass('text-success');
// The one the change is about stands out, and says whether the tick is set or taken off.
const cleo = screen.getByRole('img', { name: `Cleo: ${money(28)}, Not paid` });
const cleo = screen.getByRole('img', { name: `Cleo: ${moneyName(28)}, Not paid` });
expect(cleo.firstElementChild).toHaveClass('bg-accent');
expect(cleo.querySelector('.lucide-check')).toBeNull();
first.unmount();
const ticked = expense({ shares: split.shares.map((share) => ({ ...share, paid: true })), highlight: [CLEO.id] });
const tick = inView(<ExpenseBody card={expenses([ticked])} item={changeItem('t', null)} />);
const paid = screen.getByRole('img', { name: `Cleo: ${money(28)}, Paid` });
const paid = screen.getByRole('img', { name: `Cleo: ${moneyName(28)}, Paid` });
expect(paid.firstElementChild).toHaveClass('bg-accent');
expect(paid.querySelector('.lucide-check')).not.toBeNull();
tick.unmount();
@@ -185,7 +181,7 @@ describe('an expense in a change row', () => {
const about = expense({ shares: crowd, highlight: [crowd[2]?.person.id ?? 0] });
const third = inView(<ExpenseBody card={expenses([about])} item={changeItem('d', null)} />);
expect(screen.queryByText(`${money(14)} per person`)).toBeNull();
const standsOut = screen.getByRole('img', { name: `Cleo: ${money(14)}, Not paid` });
const standsOut = screen.getByRole('img', { name: `Cleo: ${moneyName(14)}, Not paid` });
expect(standsOut.firstElementChild).toHaveClass('bg-accent');
third.unmount();
@@ -6,7 +6,6 @@
* change row (the amount it replaces struck through, the people it no longer
* moves money between, the two faces under the row).
*/
import type { AssistantMessage } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -19,14 +18,8 @@ import { type AssistantView, AssistantViewContext } from '../../assistantViewCon
import PaymentsCard, { PaymentAside, PaymentBody, PaymentChangeFacts } from './PaymentsCard';
const AUTH = useAuthStore.getState();
const MESSAGE: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [],
};
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en');
// Intl puts a no-break space before the symbol, and text and label queries read it as a plain one.
const money = (amount: number, currency = 'EUR') => formatMoney(amount, currency, 'en').replace(/[\u00a0\u202f]/g, ' ');
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
@@ -45,7 +38,7 @@ describe('payments', () => {
it('FE-AI-CARD-BUD-015: the history names who paid whom, how much, when and why, and opens the Costs tab', () => {
const onOpen = vi.fn();
const card = payments([payment({ note: 'Bank transfer' }), payment({ id: 6, from: CLEO, to: BEN, date: null })], 2);
inView(<PaymentsCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { onOpen });
inView(<PaymentsCard cards={[{ toolCallId: 'r1', card }]} />, { onOpen });
expect(screen.getByRole('region', { name: 'Settle history' })).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getAllByText(money(42.5))).toHaveLength(2);
@@ -6,11 +6,7 @@
* middle), the entries no rate converts, the payments already recorded, and
* each traveller's share with how much of it is marked paid.
*/
import type {
AssistantMessage,
CostPerPersonCard as CostPerPersonCardData,
SettlementCard as SettlementData,
} from '@trek/shared';
import type { CostPerPersonCard as CostPerPersonCardData, SettlementCard as SettlementData } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -24,14 +20,8 @@ import CostPerPersonCard from './CostPerPersonCard';
import SettlementCard from './SettlementCard';
const AUTH = useAuthStore.getState();
const MESSAGE: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [],
};
const money = (amount: number) => formatMoney(amount, 'EUR', 'en');
// Intl puts a no-break space before the symbol, and text and label queries read it as a plain one.
const money = (amount: number) => formatMoney(amount, 'EUR', 'en').replace(/[\u00a0\u202f]/g, ' ');
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
@@ -70,7 +60,7 @@ afterEach(() => {
describe('settle up', () => {
it('FE-AI-CARD-BUD-018: names who pays whom, the balances on their bars and the payments already recorded', () => {
const onOpen = vi.fn();
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: settle() }]} message={MESSAGE} />, { onOpen });
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: settle() }]} />, { onOpen });
expect(screen.getByRole('region', { name: 'Settle up' })).toBeInTheDocument();
expect(screen.getByText(money(42.5))).toBeInTheDocument();
expect(screen.getByText(money(18))).toBeInTheDocument();
@@ -90,7 +80,7 @@ describe('settle up', () => {
recorded: 0,
unconverted: { entries: 0, currencies: [] },
});
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: square }]} message={MESSAGE} />);
inView(<SettlementCard cards={[{ toolCallId: 'r1', card: square }]} />);
expect(screen.getByText("Everyone's square")).toBeInTheDocument();
expect(screen.getByText('No payments outstanding right now.')).toBeInTheDocument();
expect(screen.getByText(money(0))).toHaveClass('text-content-faint');
@@ -100,7 +90,7 @@ describe('settle up', () => {
it('FE-AI-CARD-BUD-020: a long settle up counts the payments and the balances it leaves out', () => {
const card = settle({ moreFlows: 3, moreBalances: 7 });
inView(<SettlementCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />);
inView(<SettlementCard cards={[{ toolCallId: 'r1', card }]} />);
expect(screen.getByText('3 more')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('7 more')).toBeInTheDocument();
@@ -118,7 +108,7 @@ describe('the share per traveller', () => {
],
more: 1,
};
inView(<CostPerPersonCard cards={[{ toolCallId: 'r1', card }]} message={MESSAGE} />, { onOpen: vi.fn() });
inView(<CostPerPersonCard cards={[{ toolCallId: 'r1', card }]} />, { onOpen: vi.fn() });
expect(screen.getByRole('region', { name: 'Per Person' })).toBeInTheDocument();
expect(screen.getByText(money(69))).toBeInTheDocument();
expect(screen.getByText('2 expenses')).toBeInTheDocument();
@@ -80,7 +80,8 @@ export function mergePayments(entries: readonly CardEntry[]): PaymentsCard | nul
export function latestOf(entries: readonly CardEntry[], kind: 'settlement'): SettlementCard | null;
export function latestOf(entries: readonly CardEntry[], kind: 'costPerPerson'): CostPerPersonCard | null;
export function latestOf(entries: readonly CardEntry[], kind: 'settlement' | 'costPerPerson') {
return cardsOf(entries, kind).at(-1) ?? null;
const cards = cardsOf(entries, kind);
return cards[cards.length - 1] ?? null;
}
/** How many people of a split get a badge of their own before an even one reads as a stack. */
@@ -102,7 +102,7 @@ describe('the journal entries card', () => {
it('FE-AI-JOURNEY-002: an entry without a photo takes the width, and a known mood and weather are named', () => {
const { container } = block([card([look({ photoId: null, mood: 'amazing', weather: 'cold' })])]);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('img[src^="/api/photos/"]')).toBeNull();
expect(screen.getByText('Amazing')).toBeInTheDocument();
// `cold` is the id the journal stores for snow.
expect(screen.getByText('Snowy')).toBeInTheDocument();
@@ -103,7 +103,7 @@ describe('TodoListCard', () => {
});
it("FE-AI-TODO-002: priorities take the panel tones; a passed due date turns red by the reader's own day", () => {
inView(<TodoListBlock cards={[{ toolCallId: 'call_1', card: ANSWER }]} />);
inView(<TodoListBlock cards={[{ toolCallId: 'call_1', card: ANSWER }]} />, { onOpen: vi.fn() });
expect(screen.getByText('P1').closest('span')).toHaveClass('text-danger');
expect(screen.getByText('P2').closest('span')).toHaveClass('text-warning');
const due = screen.getAllByRole('button', { name: /Show this day in the plan/ });
@@ -63,9 +63,9 @@ function packingGroupKey(group: PackingCardGroup): string {
}
/** Rows joined by id, a later card's row replacing an earlier one; rows without an id are all kept. */
function joinRows<T extends { id: number | null }>(earlier: readonly T[], later: readonly T[]): T[] {
const ids = new Set(later.flatMap((row) => (row.id === null ? [] : [row.id])));
return [...earlier.filter((row) => row.id === null || !ids.has(row.id)), ...later];
function joinRows<T extends { id?: number | null }>(earlier: readonly T[], later: readonly T[]): T[] {
const ids = new Set(later.flatMap((row) => (typeof row.id === 'number' ? [row.id] : [])));
return [...earlier.filter((row) => typeof row.id !== 'number' || !ids.has(row.id)), ...later];
}
/** Whether a card shows every row it counts, so its figures can be counted again from its rows. */
@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { charger, DRIVE, FLORENCE_DAY, MUNICH_DAY } from '../../../../../tests/helpers/assistantRoadtrip';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import RoadtripBlock from './RoadtripBlock';
@@ -122,7 +122,9 @@ describe('RoadtripBlock', () => {
});
it('FE-AI-RT-016: opens on the day the chat was opened on and says where the battery runs dry', () => {
useAssistantStore.setState({ focus: { dayId: FLORENCE_DAY.dayId } });
useAssistantStore.setState((s) => ({
byTrip: { ...s.byTrip, 4: { ...chatOf(s, 4), focus: { dayId: FLORENCE_DAY.dayId } } },
}));
const dry = { ...DRIVE, days: [MUNICH_DAY, { ...FLORENCE_DAY, dryKm: 280 }] };
renderBlock([{ toolCallId: 'call_1', card: dry }]);
expect(screen.getByText('Florence')).toBeInTheDocument();
@@ -3,7 +3,7 @@ import { CalendarDays, CarFront, Crosshair, MapPin, Route, TriangleAlert, Zap }
import { type ReactNode, useMemo, useState } from 'react';
import { useTranslation } from '../../../../i18n';
import { useAssistantStore } from '../../../../store/assistantStore';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { SoftPill } from '../../../Planner/planParts';
import { dayColor } from '../../../Roadtrip/dayColors';
import { formatDurationShort } from '../../../Roadtrip/roadtripModel';
@@ -60,11 +60,13 @@ export default function RoadtripDrive({
variants?: ReactNode;
}) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const { tripId, variant } = useAssistantView();
const format = useCardFormat();
const map = useRoadtripCardMap();
// The day the chat was opened on, read once: the card opens there, and on the first driving day otherwise.
const [selected, setSelected] = useState(() => defaultDayIndex(card, useAssistantStore.getState().focus?.dayId));
const [selected, setSelected] = useState(() =>
defaultDayIndex(card, chatOf(useAssistantStore.getState(), tripId).focus?.dayId)
);
const tiles = useMemo(() => card.days.map(toRoadtripDay), [card]);
const day = card.days[selected] ?? card.days[0];
const railDay = tiles[selected] ?? tiles[0];
@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest';
import { charger, DRIVE, FLORENCE_DAY, MUNICH_DAY } from '../../../../../tests/helpers/assistantRoadtrip';
import { dayColor } from '../../../Roadtrip/dayColors';
import { CARD_RULES } from '../cardLooks';
import type { EntityLook } from '../cardTypes';
import {
chargersOnDay,
dayHasWarning,
@@ -143,7 +144,7 @@ describe('roadtripCardModel', () => {
});
it('FE-AI-RT-007: an applied road trip change opens its day in the road trip and shows the day on the map', () => {
const look = ROADTRIP_LOOK.entities.roadtrip;
const look: EntityLook = ROADTRIP_LOOK.entities.roadtrip;
const applied = { toolCallId: 'call_1', entity: 'roadtrip' as const, ids: [], dayId: 31, undoUntil: null };
expect(look.onMap?.(applied, null)).toEqual({ placeId: null, dayId: 31 });
expect(look.open?.(applied, null)).toEqual({ kind: 'tab', tab: 'roadtrip', params: { day: '31' } });
@@ -50,7 +50,9 @@ function stopOf(stop: RoadtripCardStop, index: number, dayId: number): RoadtripS
stopType: stop.stopType,
fillPercent: stop.fillPercent,
offRoadMeters: stop.offRoadM,
...(stop.carrier ? { carrier: stop.carrier } : {}),
...(stop.carrier
? { carrier: { ...stop.carrier, code: stop.carrier.code ?? null, at: stop.carrier.at ?? null } }
: {}),
...(stop.autoNight ? { automaticNight: stop.autoNight } : {}),
...(stop.bookend
? {
@@ -1,5 +1,6 @@
import type { TourRouteCard, TourRouteSchedule } from '@trek/shared';
import { Clock, Droplet, Sunrise, Sunset } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../../i18n';
import { SoftPill } from '../../../Planner/planParts';
@@ -29,12 +30,12 @@ function Span({ span, className }: { span: BarSpan; className: string }) {
/** One hour of the forecast: the hour, the sky, the temperature, and the rain when it is likely. */
function HourCell({ hour }: { hour: TourRouteSchedule['hours'][number] }) {
const Icon = weatherIconFor(hour.main);
const icon = weatherIconFor(hour.main);
const rain = hour.rainPct !== null && hour.rainPct >= RAIN_SHOWN_PCT ? hour.rainPct : null;
return (
<li className="flex w-9 flex-col items-center gap-0.5 text-caption tabular-nums text-content-secondary">
<span className="text-content-faint">{String(hour.hour).padStart(2, '0')}</span>
<Icon size={14} strokeWidth={2} aria-hidden="true" />
{createElement(icon, { size: 14, strokeWidth: 2, 'aria-hidden': true })}
<span>{`${hour.tempC}°`}</span>
{rain !== null && (
<span className="inline-flex items-center text-info">
@@ -1,5 +1,6 @@
import type { TourRouteCard } from '@trek/shared';
import { CalendarDays, Clock, MoveHorizontal, TrendingDown, TrendingUp, type LucideIcon } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
@@ -66,12 +67,8 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) {
const Icon = tourDayRowIcon(card.activity);
return (
<SoftPill icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t(TOUR_TYPE_WORDS[card.activity].labelKey)}
</SoftPill>
);
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
}
const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5;
@@ -1,5 +1,6 @@
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../../i18n';
import {
@@ -55,7 +56,6 @@ function StopChip({
}) {
const { t } = useTranslation();
const format = useCardFormat();
const Icon = iconOf(stop);
const name = stopName(stop, t);
const along = stop.role === 'along';
const off =
@@ -65,7 +65,12 @@ function StopChip({
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
const clock = stop.clock ? format.clock(stop.clock) : null;
const label = [name, clock, off, stay].filter(Boolean).join(', ');
const glyph = <Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />;
const glyph = createElement(iconOf(stop), {
size: 10,
strokeWidth: 2.2,
'aria-hidden': true,
className: 'flex-none text-content-faint',
});
const body = (
<>
<bdi className="max-w-[10rem] truncate">{name}</bdi>
@@ -44,11 +44,7 @@ const round = (value: number) => Math.round(value * 100) / 100;
* Null when the day knows neither the sun nor a start.
*/
export function dayBarOf(schedule: TourRouteSchedule): DayBar | null {
const sunrise = schedule.sunrise ? clockMinutes(schedule.sunrise) : null;
const sunset = schedule.sunset ? clockMinutes(schedule.sunset) : null;
const start = schedule.start ? clockMinutes(schedule.start) : null;
let arrive = schedule.arrive ? clockMinutes(schedule.arrive) : null;
if (start !== null && arrive !== null && (schedule.nextDay || arrive < start)) arrive += DAY_MINUTES;
const { sunrise, sunset, start, arrive } = barClocks(schedule);
const known = [sunrise, sunset, start, arrive].filter((value): value is number => value !== null);
if (known.length === 0) return null;
const from = Math.max(0, Math.floor(Math.min(...known) / 60) * 60 - 60);
@@ -67,6 +63,20 @@ export function dayBarOf(schedule: TourRouteSchedule): DayBar | null {
return { daylight, walk, dark };
}
/** The day's clocks in minutes from its midnight, null where it names none; an arrival past midnight counts on. */
function barClocks(schedule: TourRouteSchedule): {
sunrise: number | null;
sunset: number | null;
start: number | null;
arrive: number | null;
} {
const minutesOf = (clock: string | null | undefined) => (clock ? clockMinutes(clock) : null);
const start = minutesOf(schedule.start);
let arrive = minutesOf(schedule.arrive);
if (start !== null && arrive !== null && (schedule.nextDay || arrive < start)) arrive += DAY_MINUTES;
return { sunrise: minutesOf(schedule.sunrise), sunset: minutesOf(schedule.sunset), start, arrive };
}
/** The forecast's hours to show as cells: only a forecast's, since a climate's are last year's. */
export function forecastHours(schedule: TourRouteSchedule): TourRouteSchedule['hours'] {
return schedule.weather === 'forecast' ? schedule.hours : [];
@@ -14,7 +14,8 @@ const TAKE_TOOL = 'take_proposal';
/** Whether two routes are one way: one activity, as many points, the same ends. */
function sameWay(a: TourRouteCard, b: TourRouteCard): boolean {
if (a.activity !== b.activity || a.line.length !== b.line.length || a.stops.length !== b.stops.length) return false;
const ends = (card: TourRouteCard) => [card.line[0], card.line.at(-1)].map((point) => point?.slice(0, 2).join(','));
const ends = (card: TourRouteCard) =>
[card.line[0], card.line[card.line.length - 1]].map((point) => point?.slice(0, 2).join(','));
const [aStart, aEnd] = ends(a);
const [bStart, bEnd] = ends(b);
return aStart === bStart && aEnd === bEnd;
@@ -14,22 +14,16 @@ import { canTake, routePoints } from './tourRouteModel';
import { takeInThread } from './tourTakeModel';
/**
* What a route card and the phone's route peek share: the light between the
* card and its line on the map (resting on one lights the other), the spot
* of the route in focus (the profile, a stop chip and the map's pointer all
* move one), and "On map", which frames the route (a ways card frames all
* its ways, `frame`); on the phone the chat steps aside first and the route's
* peek opens over the map.
* What a route card and the phone's route peek share: the spot of the route
* in focus, which the profile, a stop chip and the map's pointer all move.
* A card or a peek that goes takes its spot on the map with it.
*/
export function useTourRouteCard(toolCallId: string, card: TourRouteCard, frame?: readonly TourRouteCard[]) {
const { tripId, variant, onRevealMap } = useAssistantView();
const hover = useChangeHover(tripId, toolCallId);
export function useRouteFocus(toolCallId: string) {
const { tripId } = useAssistantView();
const focus = useAssistantStore((s) =>
s.routeFocus?.tripId === tripId && s.routeFocus.ref === toolCallId ? s.routeFocus.focus : null
);
const setRouteFocus = useAssistantStore((s) => s.setRouteFocus);
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setRoutePeek = useAssistantStore((s) => s.setRoutePeek);
/** Puts the focus out, if it is still this card's. */
const release = useCallback(() => {
@@ -45,9 +39,26 @@ export function useTourRouteCard(toolCallId: string, card: TourRouteCard, frame?
[tripId, toolCallId, setRouteFocus, release]
);
// A card that goes takes its spot on the map with it.
useEffect(() => release, [release]);
return { focus, setFocus };
}
/**
* A route card: the light between the card and its line on the map (resting
* on one lights the other), the spot of the route in focus, and "On map",
* which frames the route (a ways card frames all its ways, `frame`); on the
* phone the chat steps aside first and the route's peek opens over the map.
* The peek itself takes only the focus: its light is the map's, which
* moveRoutePeek moves, so closing it leaves a light the chat put on alone.
*/
export function useTourRouteCard(toolCallId: string, card: TourRouteCard, frame?: readonly TourRouteCard[]) {
const { tripId, variant, onRevealMap } = useAssistantView();
const hover = useChangeHover(tripId, toolCallId);
const { focus, setFocus } = useRouteFocus(toolCallId);
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setRoutePeek = useAssistantStore((s) => s.setRoutePeek);
const showOnMap = useCallback(() => {
const points = (frame ?? [card]).flatMap(routePoints);
if (points.length === 0) return;
@@ -87,7 +87,7 @@ function Line({ line, view, mode, withYear }: { line: BalanceLine; view: VacayVi
const viewerId = useViewerId();
const person = view.people.find((candidate) => candidate.id === line.personId);
const color = personColor(view, line.personId);
const { left, leftAfter, over } = lineFigures(line);
const { leftNow, leftAfter, over } = lineFigures(line);
const changes = line.delta !== 0 || line.totalAfter !== null;
const total = line.totalAfter ?? line.total;
const name = line.personId === viewerId ? t('assistant.card.common.you') : (person?.name ?? `#${line.personId}`);
@@ -95,7 +95,7 @@ function Line({ line, view, mode, withYear }: { line: BalanceLine; view: VacayVi
const meter = t('assistant.card.vacay.meter', {
used: fmtDays(line.used),
planned: fmtDays(Math.max(0, line.delta)),
left: fmtDays(Math.max(0, leftAfter)),
remaining: fmtDays(Math.max(0, leftAfter)),
total: fmtDays(total),
});
return (
@@ -107,13 +107,13 @@ function Line({ line, view, mode, withYear }: { line: BalanceLine; view: VacayVi
<span className={FIGURES}>
{changes && (
<>
<span className={FIGURE}>{fmtDays(left)}</span>
<span className={FIGURE}>{fmtDays(leftNow)}</span>
<ArrowRight size={12} aria-hidden="true" className={ARROW} />
</>
)}
{mode === 'read' && !changes && <span className={CAPTION}>{usedOf}</span>}
<span className={changes ? FIGURE : CAPTION}>
{t('assistant.card.vacay.daysLeft', { count: Math.max(0, changes ? leftAfter : left) })}
{t('assistant.card.vacay.daysLeft', { count: Math.max(0, changes ? leftAfter : leftNow) })}
</span>
</span>
</div>
@@ -124,7 +124,7 @@ describe('vacayCardModel', () => {
const view = mergeVacayCards(items.map((entry) => entry.card as VacayCard));
const [line] = balanceLines(view, placedProposals(items, new Set()));
expect(line).toMatchObject({ personId: 7, year: 2026, used: 28, delta: 1.5, totalAfter: null });
expect(line && lineFigures(line)).toEqual({ left: 2, leftAfter: 0.5, over: 0 });
expect(line && lineFigures(line)).toEqual({ leftNow: 2, leftAfter: 0.5, over: 0 });
const [without] = balanceLines(view, placedProposals(items, new Set(['c1', 'c2'])));
expect(without?.delta).toBe(-0.5);
@@ -136,7 +136,7 @@ describe('vacayCardModel', () => {
const raised = allowancesOf([...items, item('c5', 'pending', allowance)], new Set());
expect(raised.get('7:2026')).toBe(26);
const [tight] = balanceLines(view, placedProposals(items, new Set()), raised);
expect(tight && lineFigures(tight)).toEqual({ left: 2, leftAfter: -3.5, over: 3.5 });
expect(tight && lineFigures(tight)).toEqual({ leftNow: 2, leftAfter: -3.5, over: 3.5 });
expect(allowancesOf([item('c5', 'pending', allowance)], new Set(['c5'])).size).toBe(0);
// The same day held twice the same way is taken and cleared again: it counts for nothing.
@@ -213,10 +213,10 @@ export function allowancesOf(items: readonly ChangeItem[], excluded: ReadonlySet
}
/** What a line comes to: the days left before and after, and how far past the allowance it would go. */
export function lineFigures(line: BalanceLine): { left: number; leftAfter: number; over: number } {
export function lineFigures(line: BalanceLine): { leftNow: number; leftAfter: number; over: number } {
const total = line.totalAfter ?? line.total;
const leftAfter = total - (line.used + line.delta);
return { left: line.total - line.used, leftAfter, over: Math.max(0, -leftAfter) };
return { leftNow: line.total - line.used, leftAfter, over: Math.max(0, -leftAfter) };
}
/** Whether the lines need their year named: two years in one card, or a year that is not this one. */
@@ -10,7 +10,7 @@ import { TourGrade, TourRouteFigures } from '../cards/tour/TourRouteHead';
import { profileSamples, WAY_KEYS } from '../cards/tour/tourRouteModel';
import TourRouteProfile from '../cards/tour/TourRouteProfile';
import { routeName } from '../cards/tour/tourRouteWords';
import { useTourRouteCard } from '../cards/tour/useTourRouteCard';
import { useRouteFocus } from '../cards/tour/useTourRouteCard';
import { moveRoutePeek } from './routePeekControl';
import { type PeekRoute, peekRoute } from './routePeekModel';
@@ -21,7 +21,7 @@ function PeekBody({ route }: { route: PeekRoute }) {
const { entry, ways, blockKey } = route;
const { card, toolCallId } = entry;
const pick = useAssistantStore((s) => s.pick);
const shown = useTourRouteCard(toolCallId, card, ways.length > 0 ? ways.map((way) => way.card) : undefined);
const shown = useRouteFocus(toolCallId);
const samples = useMemo(() => profileSamples(card), [card]);
const index = ways.findIndex((way) => way.toolCallId === toolCallId);
const color = variantPaintCss(index >= 0 ? (WAY_KEYS[index] ?? 'a') : 'current');
@@ -1,6 +1,7 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from '../assistantParts';
import type { CardEntry } from '../cards/cardTypes';
import { routeEntries, type RouteEntry, wayGroups } from '../cards/tour/tourRouteModel';
/**
@@ -15,17 +16,24 @@ export interface PeekRoute {
blockKey: string;
}
/** The ways of an answer's cards that hold the call, as the chat groups them; null when none does. */
function peekInCards(entries: readonly CardEntry[], toolCallId: string): PeekRoute | null {
for (const group of wayGroups(routeEntries(entries))) {
const entry = group.find((way) => way.toolCallId === toolCallId);
if (!entry) continue;
const ways = group.length >= 2 ? group : [];
return { entry, ways, blockKey: `tour:${group[0]?.toolCallId ?? ''}` };
}
return null;
}
/** The route card of a call, laid out as the chat lays it out; null when the chat holds no route under it. */
export function peekRoute(messages: readonly AssistantMessage[], toolCallId: string): PeekRoute | null {
for (const message of messages) {
for (const block of messageBlocks(message, false)) {
if (block.kind === 'cards') {
for (const group of wayGroups(routeEntries(block.entries))) {
const entry = group.find((way) => way.toolCallId === toolCallId);
if (!entry) continue;
const ways = group.length >= 2 ? group : [];
return { entry, ways, blockKey: `tour:${group[0]?.toolCallId ?? ''}` };
}
const found = peekInCards(block.entries, toolCallId);
if (found) return found;
} else if (block.kind === 'change') {
const card = block.items.find((item) => item.toolCallId === toolCallId)?.card;
if (card?.kind === 'tourRoute') return { entry: { toolCallId, card }, ways: [], blockKey: '' };
@@ -8,9 +8,9 @@ import { useAuthStore } from '../../store/authStore';
* shows (its Google place id, its position and its name).
*/
interface PhotoPlace {
photoKey: string | null;
lat: number | null;
lng: number | null;
photoKey?: string | null;
lat?: number | null;
lng?: number | null;
name: string;
}
@@ -150,8 +150,9 @@ describe('AssistantGhostLineLayer', () => {
expect(asked(paths()[1])).toMatchObject({ dash: '6 7' });
expect(bringToFront).not.toHaveBeenCalled();
// The same line, now lit: its dash is asked away by name, so Leaflet's merged style drops it.
rerender(<AssistantGhostLineLayer lines={[other, { ...way, hot: true }]} />);
// The same line, now lit, which the route model draws solid: its dash is asked away by name,
// so Leaflet's merged style drops it.
rerender(<AssistantGhostLineLayer lines={[other, { ...way, dashed: false, hot: true }]} />);
const lit = screen.getAllByTestId('group')[1];
const core = lit?.querySelectorAll<HTMLElement>('[data-testid="polyline"]')[1];
expect(asked(core)).toMatchObject({ dash: '', dashNamed: 'true' });
@@ -81,11 +81,15 @@ describe('useTourDeepLink', () => {
late.planner.tours = [{ place_id: 41 }];
late.hook.rerender();
expect(late.workspace.openTourInPlanner).toHaveBeenCalledWith(41);
act(() => vi.advanceTimersByTime(3000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(late.planner.toast.error).not.toHaveBeenCalled();
const loading = setup({ tours: [], ready: false });
act(() => vi.advanceTimersByTime(5000));
act(() => {
vi.advanceTimersByTime(5000);
});
expect(loading.planner.invalidateTourPlaceIds).not.toHaveBeenCalled();
expect(loading.planner.toast.error).not.toHaveBeenCalled();
expect(loading.params.get('tour')).toBe('41');
@@ -93,7 +97,9 @@ describe('useTourDeepLink', () => {
const missing = setup({ tours: [] });
missing.hook.rerender();
expect(missing.planner.invalidateTourPlaceIds).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(3000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(missing.planner.toast.error).toHaveBeenCalledWith('tours.planner.openFailed');
expect(missing.params.get('tour')).toBeNull();
expect(missing.workspace.openTourInPlanner).not.toHaveBeenCalled();
@@ -92,16 +92,10 @@ function useCssColor(): (value: string) => string {
const theme = useMapPaint(THEME_PROBE);
return useMemo(() => {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
const resolved = new Map<string, string>();
return (value: string) => {
const token = /^var\((--[\w-]+)\)$/.exec(value.trim())?.[1];
if (!token) return value;
let color = resolved.get(token);
if (color === undefined) {
color = style?.getPropertyValue(token).trim() || theme.accent;
resolved.set(token, color);
}
return color;
return style?.getPropertyValue(token).trim() || theme.accent;
};
}, [theme]);
}
@@ -193,16 +187,14 @@ export function useAssistantRouteLines({ tripId, isMobile, autoAllowed }: RouteL
const ghosts = useMemo(
() =>
cardGhosts(refs, CARD_MAP_LOOK).map(
(ghost): AssistantGhost => ({
id: ghost.id,
ref: ghost.ref,
lat: ghost.lat,
lng: ghost.lng,
name: ghost.name,
variant: ghost.variant ?? 'pin',
})
),
cardGhosts(refs, CARD_MAP_LOOK).map((ghost): AssistantGhost => ({
id: ghost.id,
ref: ghost.ref,
lat: ghost.lat,
lng: ghost.lng,
name: ghost.name,
variant: ghost.variant ?? 'pin',
})),
[refs]
);
const hiddenGhosts = useMemo(() => cardHiddenGhosts(refs, CARD_MAP_LOOK), [refs]);