mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(ci): typed card points, the managed take route, and the failing card tests and ratchets
This commit is contained in:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user