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]);
+2 -2
View File
@@ -16,8 +16,8 @@
"no-misleading-character-class": 1
},
"tests": {
"@typescript-eslint/no-explicit-any": 1564,
"@typescript-eslint/no-non-null-assertion": 1757,
"@typescript-eslint/no-explicit-any": 1562,
"@typescript-eslint/no-non-null-assertion": 1755,
"@typescript-eslint/no-require-imports": 25,
"@typescript-eslint/no-unsafe-function-type": 7,
"@typescript-eslint/no-unused-vars": 96,
@@ -51,26 +51,29 @@ export class TrekPhotoSignalsRepository extends TrekRepository<TrekPhotoSignals>
/** The measurements stored for these photos, whatever version made them; a photo without one is left out. */
async listForPhotos(photoIds: number[]): Promise<TrekPhotoSignalRow[]> {
const out: TrekPhotoSignalRow[] = [];
for (let i = 0; i < photoIds.length; i += IN_CHUNK) {
const rows = await this.db()
.selectFrom('trek_photo_signals')
.select([
'photo_id',
'algo',
'phash',
'sharpness',
'luma',
'clipped',
'orig_width',
'orig_height',
'computed_at',
])
.where('photo_id', 'in', photoIds.slice(i, i + IN_CHUNK))
.execute();
out.push(...rows);
}
return out;
const chunks = Array.from({ length: Math.ceil(photoIds.length / IN_CHUNK) }, (_, i) =>
photoIds.slice(i * IN_CHUNK, (i + 1) * IN_CHUNK),
);
const found = await Promise.all(
chunks.map((chunk) =>
this.db()
.selectFrom('trek_photo_signals')
.select([
'photo_id',
'algo',
'phash',
'sharpness',
'luma',
'clipped',
'orig_width',
'orig_height',
'computed_at',
])
.where('photo_id', 'in', chunk)
.execute(),
),
);
return found.flat();
}
/** Stores a measurement, replacing the photo's earlier one. */
@@ -11,6 +11,9 @@ import type { CollectionDetailResponse, CollectionItemsRequest, CollectionItemsR
/** The most lists one lookup opens: 50 places and 20 labels can sit in no more than 70. */
const MAX_LISTS = 70;
/** A list the lookup opened, with whether the user may take places out of it. */
type OpenedList = CollectionDetailResponse & { canDelete: boolean };
/**
* Saved places and labels by their own ids, wherever they are filed (the
* get_collection_items tool): what an assistant's card needs to name a place
@@ -30,23 +33,13 @@ export class CollectionsItemsService {
async getItems(userId: number, request: CollectionItemsRequest): Promise<CollectionItemsResponse> {
const placeIds = [...new Set(request.placeIds ?? [])];
const labelIds = [...new Set(request.labelIds ?? [])];
const listIds = new Set<number>();
for (const placeId of placeIds) {
const listId = await this.collectionsRepo.collectionIdOfPlace(placeId);
if (listId !== undefined) listIds.add(listId);
}
for (const labelId of labelIds) {
const listId = await this.labels.collectionIdOf(labelId);
if (listId !== undefined) listIds.add(listId);
}
const read: Array<CollectionDetailResponse & { canDelete: boolean }> = [];
for (const listId of [...listIds].slice(0, MAX_LISTS)) {
const detail = await this.readable(userId, listId);
if (!detail) continue;
// Places leave a list only by its owner or an admin (assertCanDelete); an editor adds.
const role = await this.collections.roleOf(userId, listId);
read.push({ ...detail, canDelete: role === 'owner' || role === 'admin' });
}
const [ofPlaces, ofLabels] = await Promise.all([
Promise.all(placeIds.map((placeId) => this.collectionsRepo.collectionIdOfPlace(placeId))),
Promise.all(labelIds.map((labelId) => this.labels.collectionIdOf(labelId))),
]);
const listIds = new Set([...ofPlaces, ...ofLabels].filter((listId): listId is number => listId !== undefined));
const opened = await Promise.all([...listIds].slice(0, MAX_LISTS).map((listId) => this.withDelete(userId, listId)));
const read = opened.filter((list): list is OpenedList => list !== null);
const wantedPlaces = new Set(placeIds);
const wantedLabels = new Set(labelIds);
return {
@@ -71,6 +64,15 @@ export class CollectionsItemsService {
};
}
/** One list as its members read it, with whether the user may take places out of it. */
private async withDelete(userId: number, listId: number): Promise<OpenedList | null> {
const detail = await this.readable(userId, listId);
if (!detail) return null;
// Places leave a list only by its owner or an admin (assertCanDelete); an editor adds.
const role = await this.collections.roleOf(userId, listId);
return { ...detail, canDelete: role === 'owner' || role === 'admin' };
}
/** One list as its members read it; null for one the user may not see or that is gone. */
private async readable(userId: number, listId: number): Promise<CollectionDetailResponse | null> {
try {
@@ -93,6 +93,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
'AssistantController.deleteThread',
'AssistantController.share',
'AssistantController.stop',
'AssistantController.takeProposal',
'AssistantController.thread',
'AssistantController.threads',
'AssistantController.undo',
+6 -3
View File
@@ -299,16 +299,19 @@ describe('journey read tools', () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const journey = await seedJourney(h, 'Berlin 2026');
for (const [entry_date, title] of [
const days = [
['2026-10-20', 'Before'],
['2026-10-21', 'Yesterday'],
['2026-10-22', 'Today'],
] as const) {
] as const;
// One after the other, in this order.
await days.reduce(async (previous, [entry_date, title]) => {
await previous;
await h.client.callTool({
name: 'create_journey_entry',
arguments: { journeyId: journey.id, entry_date, title },
});
}
}, Promise.resolve());
const read = async (range: Record<string, string>) =>
parseToolResult(
await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId: journey.id, ...range } }),
@@ -218,7 +218,6 @@ describe('AssistantProposalsService', () => {
await expect(take(c, 'w1')).rejects.toMatchObject({ status: 429, code: 'too_many_requests' });
expect(await calls(c.threadId)).toHaveLength(1);
});
});
it('ASTPROP-008: a walk stored cut down and masked is created from the tool asked again, named and on no day', async () => {
const c = await conversation();
@@ -72,7 +72,8 @@ beforeAll(async () => {
orm = app.get(MikroORM);
registry = app.get(McpRegistryService);
tools = app.get(AssistantToolsService);
await Promise.all(['packing', 'budget', 'collections'].map((addon) => setAddonEnabled(orm, addon, true)));
const addons = ['packing', 'budget', 'collections', 'journey', 'roadtrip'];
await Promise.all(addons.map((addon) => setAddonEnabled(orm, addon, true)));
const { user } = await makeUser(orm);
listed = await listRaw(user.id);
});
@@ -129,7 +129,8 @@ describe('the to-do change cards', () => {
});
it("ASTTODO-007: a list's default people show the ones it will have and the ones it drops", () => {
const loaded = { assignees: { Bookings: [{ user_id: 2 }] } };
// What the loader hands on: the read's assignees by list name, unwrapped.
const loaded = { Bookings: [{ user_id: 2 }] };
const card = todos(change('set_todo_category_assignees', { categoryName: 'Bookings', userIds: [1] }, loaded));
expect(card.groups).toEqual([
{
@@ -150,7 +150,9 @@ describe('via change cards', () => {
['Seefeld', true],
['Bolzano', true],
]);
const applied = appliedChange('import_google_maps_route', 'call_9', args, { place_ids: [41, 42] }, 31, null);
// The way back takes the visits it added, not the places.
const result = { place_ids: [51, 52], assignment_ids: [41, 42] };
const applied = appliedChange('import_google_maps_route', 'call_9', args, result, 31, null);
expect(applied).toMatchObject({ entity: 'assignment', ids: [41, 42], dayId: 31 });
});
@@ -186,8 +186,9 @@ describe('calculate_roadtrip card', () => {
const built = roadtripAnswerCard(input(answer([day(3)]))) as RoadtripCard;
const lines = built.days[0].lines;
expect(lines.length).toBe(2);
// No line reaches from Bavaria to Rome: the flight between the two terminals is no drive.
expect(lines.some((line) => line.some(([lat]) => lat > 47) && line.some(([lat]) => lat < 42))).toBe(false);
const points = lines.flat();
expect(points.some(([lat]) => lat > 42 && lat < 48)).toBe(false);
for (const [lat, lng] of points) {
expect(Math.round(lat * 1e5) / 1e5).toBe(lat);
expect(Math.round(lng * 1e5) / 1e5).toBe(lng);
@@ -452,8 +452,8 @@ describe('a held company holiday and allowance', () => {
expect(whole.proposal?.notes).toEqual([]);
// Company holidays off in the plan: the planner would refuse it, the tool writes it, the card warns.
const companyOff = plan({ company_holidays_enabled: false });
const off = await loaded({ ...CALENDAR_YEAR, [readKey('get_vacay_plan')]: companyOff });
const unseen = change('toggle_company_holiday', { date: '2026-10-03' }, off);
const offFound = await loaded({ ...CALENDAR_YEAR, [readKey('get_vacay_plan')]: companyOff });
const unseen = change('toggle_company_holiday', { date: '2026-10-03' }, offFound);
expect(unseen.proposal?.notes).toEqual(['companyOff', 'publicHoliday']);
});
@@ -19,8 +19,8 @@ import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { JourneyBookPhotosMcp } from '../../../src/nest/journey/journey-book-photos.mcp';
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
import { JourneyBookMcp } from '../../../src/nest/journey/journey-book.mcp';
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
import { PhotoResolverService, type PhotoSignalSource } from '../../../src/nest/memories/photo-resolver.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys';
@@ -49,7 +49,8 @@ beforeAll(async () => {
});
mcp = t.get(JourneyBookPhotosMcp);
orm = await createTestOrm(db);
jpeg = Buffer.from(await new Jimp({ width: 400, height: 300, color: 0x6699ccff }).getBuffer('image/jpeg'));
// Large enough to print on a page, so a measured photo is not set aside as low resolution.
jpeg = Buffer.from(await new Jimp({ width: 1600, height: 1200, color: 0x6699ccff }).getBuffer('image/jpeg'));
});
beforeEach(() => {
resetTestDb(db);
@@ -13,8 +13,8 @@
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
import { JourneyBookMcp } from '../../../src/nest/journey/journey-book.mcp';
import { JourneyBookModule } from '../../../src/nest/journey/journey-book.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { addJourneyContributor, makeJourney } from '../../helpers/factories/journeys';
import { makeUser } from '../../helpers/factories/users';
@@ -75,17 +75,19 @@ describe('JourneyBookMcp', () => {
it('JBMCP-001: every tool waits for the Journey addon and reads or writes in the journey group as it is annotated', async () => {
const { user } = await makeUser(orm);
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled');
for (const method of [...READS, ...WRITES]) {
const tools = [...READS, ...WRITES].map((method) => {
const entry = getEntry(JourneyBookMcp as unknown as ClassRef, method);
if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`);
const reads = READS.includes(method);
expect(entry.options.access, method).toEqual({ group: 'journey', mode: reads ? 'read' : 'write' });
expect(entry.options.annotations?.readOnlyHint, method).toBe(reads);
enabled.mockResolvedValue(false);
expect(await entry.options.when?.(ctx(user.id), mcp), method).toBe(false);
enabled.mockResolvedValue(true);
expect(await entry.options.when?.(ctx(user.id), mcp), method).toBe(true);
}
return entry;
});
const gates = () => Promise.all(tools.map((entry) => entry.options.when?.(ctx(user.id), mcp)));
enabled.mockResolvedValue(false);
expect(await gates()).toEqual(tools.map(() => false));
enabled.mockResolvedValue(true);
expect(await gates()).toEqual(tools.map(() => true));
const remove = getEntry(JourneyBookMcp as unknown as ClassRef, 'deleteBookPages');
expect(remove?.kind === 'tool' && remove.options.annotations?.destructiveHint).toBe(true);
});
@@ -394,9 +394,8 @@ describe('readForSignals: what the quality signals are measured on', () => {
const outside = await insertPhoto({ provider: 'local', file_path: 'elsewhere/p.jpg' });
const clip = await insertPhoto({ provider: 'local', file_path: 'journey/c.mp4', media_type: 'video' });
const bare = await insertPhoto({ provider: 'local' });
for (const photoId of [outside, clip, bare, 999999]) {
expect(await svc.readForSignals(photoId, 100)).toEqual({ kind: 'none' });
}
const unread = await Promise.all([outside, clip, bare, 999999].map((photoId) => svc.readForSignals(photoId, 100)));
expect(unread).toEqual([{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }]);
expect(storage.withLocalFile).toHaveBeenCalledTimes(1);
});
@@ -27,7 +27,7 @@ import {
} from '../../../../src/nest/photos/photo-signals.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makeJourney } from '../../../helpers/factories/journeys';
import { countRows, insertRow } from '../../../helpers/factories/rows';
import { countRows, insertRow, insertRows } from '../../../helpers/factories/rows';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
@@ -76,17 +76,16 @@ afterAll(async () => {
async function gallery(kinds: Array<'image' | 'video'>) {
const { user } = await makeUser(orm);
const journey = await makeJourney(orm, user.id);
const ids: number[] = [];
for (const [i, kind] of kinds.entries()) {
const id = await insertRow(orm, TrekPhotos, {
provider: 'local',
owner: user.id,
file_path: 'journey/p.jpg',
media_type: kind,
});
await insertRow(orm, JourneyPhotos, { journey: journey.id, photo: id, sort_order: i, created_at: Date.now() });
ids.push(id);
}
const ids = await insertRows(
orm,
TrekPhotos,
kinds.map((kind) => ({ provider: 'local', owner: user.id, file_path: 'journey/p.jpg', media_type: kind })),
);
await insertRows(
orm,
JourneyPhotos,
ids.map((id, i) => ({ journey: journey.id, photo: id, sort_order: i, created_at: Date.now() })),
);
return { journeyId: journey.id, ids };
}
+1
View File
@@ -87,6 +87,7 @@ function checkPluralForms(locale, keys, groups) {
*/
export const NOT_PLURAL = [
{ key: 'assistant.place.addToShort', because: 'the number of the day a found place would go on ("+ Day 3")' },
{ key: 'assistant.card.tour.days', because: 'the numbers of the days a tour lies on ("Days 2, 4")' },
{ key: 'collab.polls.optionPlaceholder', because: 'the position of a poll option ("Option 3")' },
{ key: 'collections.importOnDay', because: 'the number of the day a place is planned on' },
{ key: 'dashboard.atlas.aroundEquator', because: 'a decimal multiplier ("≈ 1.37×"), not a count of things' },
+3 -2
View File
@@ -16,7 +16,9 @@ export const cardLatSchema = z.number().min(-90).max(90);
export const cardLngSchema = z.number().min(-180).max(180);
/** A point of a line to draw, `[lat, lng]` at five decimals; single points are `{ lat, lng }` objects. */
export const cardPointSchema = z.tuple([cardLatSchema, cardLngSchema]);
export type CardPoint = [number, number];
// Typed by hand: server and client compile without strictNullChecks, where an inferred tuple makes every item optional.
export const cardPointSchema: z.ZodType<CardPoint> = z.tuple([cardLatSchema, cardLngSchema]);
export const cardDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
export const cardClockSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
@@ -52,7 +54,6 @@ export const cardDaySchema = z.object({
/** The most one card may weigh, as JSON text; a heavier one is not sent at all. */
export const MAX_CARD_CHARS = 24_000;
export type CardPoint = z.infer<typeof cardPointSchema>;
export type CardMoney = z.infer<typeof cardMoneySchema>;
export type CardPerson = z.infer<typeof cardPersonSchema>;
export type CardDay = z.infer<typeof cardDaySchema>;
@@ -47,10 +47,7 @@ function hike(extra: Partial<TourRouteCard> = {}): TourRouteCard {
[47.61, 11.31, 1670],
],
stretches: [{ facet: 'sac', fromM: 0, toM: 4000, step: 3 }],
stops: [
stop(0, { role: 'start', clock: '08:00' }),
stop(4100, { role: 'end', kind: 'peak', name: 'Hochfelln' }),
],
stops: [stop(0, { role: 'start', clock: '08:00' }), stop(4100, { role: 'end', kind: 'peak', name: 'Hochfelln' })],
stages: null,
skiScheme: null,
schedule: {
@@ -4,7 +4,7 @@ import { HIKE_UNROUTED_REASONS } from '../../tours/hike-plan.schema';
import { HIKE_WARNING_KINDS } from '../../tours/hike-schedule';
import { tourPoiCategorySchema } from '../../tours/tour-poi-category';
import { tourMaxHikingDifficultySchema, tourTypeKeySchema } from '../../tours/tours.schema';
import { cardClockSchema, cardDateSchema, cardLatSchema, cardLngSchema, cardText } from './card-parts';
import { cardClockSchema, cardDateSchema, cardLatSchema, cardLngSchema, cardPointSchema, cardText } from './card-parts';
import { z } from 'zod';
@@ -37,7 +37,13 @@ const minutes = z.number().int().min(0).max(86_400);
const sacStep = z.number().int().min(1).max(6);
/** A point of the line, `[lat, lng, height]` at five decimals; every height is null when one is missing. */
export const tourRoutePointSchema = z.tuple([cardLatSchema, cardLngSchema, height.nullable()]);
export type TourRoutePoint = [number, number, number | null];
// Typed by hand like cardPointSchema: an inferred tuple reads every item as optional where strictNullChecks is off.
export const tourRoutePointSchema: z.ZodType<TourRoutePoint> = z.tuple([
cardLatSchema,
cardLngSchema,
height.nullable(),
]);
/** What a stop is beyond its role: a kind of place along a hike, its highest point, a city walk's station. */
export const TOUR_ROUTE_STOP_KINDS = ['highest', 'station'] as const;
@@ -164,7 +170,7 @@ export const tourRouteCardSchema = z.object({
/** A held change of a saved tour's way: the way it has now, to draw faintly beside the new one. */
previous: z
.object({
line: z.array(z.tuple([cardLatSchema, cardLngSchema])).min(2).max(L.previous),
line: z.array(cardPointSchema).min(2).max(L.previous),
distanceM: metres,
ascentM: climb.nullable(),
minutes: minutes.nullable(),
+5 -6
View File
@@ -224,11 +224,7 @@ describe('pickBookPhotos', () => {
it('PHOTOPICK-010: without signals a caption wins, then a photo the book does not have yet, best first', () => {
const result = pickBookPhotos(
[
at(1, '09:00:00', { inBook: true }),
at(2, '10:00:00'),
at(3, '11:00:00', { caption: 'Tram 28', inBook: true }),
],
[at(1, '09:00:00', { inBook: true }), at(2, '10:00:00'), at(3, '11:00:00', { caption: 'Tram 28', inBook: true })],
[],
{ limit: 2 },
);
@@ -342,7 +338,10 @@ describe('pickBookPhotos', () => {
expect(ids(one)).toEqual([[101]]);
expect(one.groups[0]?.dropped).toHaveLength(30);
expect(one.groups[0]?.dropped_more).toBe(5);
const five = pickBookPhotos([1, 2, 3, 4, 5].flatMap((day) => burst(day, 31)), []);
const five = pickBookPhotos(
[1, 2, 3, 4, 5].flatMap((day) => burst(day, 31)),
[],
);
expect(bookPhotoSuggestionsSchema.parse(five)).toEqual(five);
expect(five.groups.map((g) => [g.dropped.length, g.dropped_more])).toEqual([
[30, 0],
+5 -2
View File
@@ -258,7 +258,10 @@ export function pickBookPhotos(
const chosen = filterFor(placed, options, byEntry);
const groups = groupByDayAndPlace(chosen);
const judged = groups.map((g) => judge(g, purpose, median));
const shown = takeRoundRobin(judged.map((j) => j.picks), limit);
const shown = takeRoundRobin(
judged.map((j) => j.picks),
limit,
);
const out: BookPhotoSuggestions['groups'] = [];
let groupsMore = 0;
let droppedRoom: number = PHOTO_PICK.droppedTotal;
@@ -417,7 +420,7 @@ function similarSets(members: readonly Placed[]): Placed[][] {
let r = i;
while (parent[r] !== r) r = parent[r] ?? r;
// Every index on the way now points straight at the root.
for (let k = i; parent[k] !== r; ) {
for (let k = i; parent[k] !== r;) {
const next = parent[k] ?? r;
parent[k] = r;
k = next;
+1 -1
View File
@@ -54,7 +54,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.few': 'أيام إجازة أخرى هذا العام: {count}',
'assistant.card.vacay.otherDays.many': 'أيام إجازة أخرى هذا العام: {count}',
'assistant.card.vacay.allowance': 'الاستحقاق',
'assistant.card.vacay.meter': '{used} مستخدم، {planned} مخطط، {left} متبقٍ من {total}',
'assistant.card.vacay.meter': '{used} مستخدم، {planned} مخطط، {remaining} متبقٍ من {total}',
'assistant.card.vacay.weekendBlocked': 'عطلة نهاية الأسبوع، محجوبة في خطتك',
'assistant.card.vacay.weekend': 'يوم عطلة نهاية الأسبوع، يُحسب إجازة',
'assistant.card.vacay.publicHoliday': 'عطلة رسمية، لا حاجة لإجازة',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Bu il daha {count} istirahət günü',
'assistant.card.vacay.otherDays.one': 'Bu il daha {count} istirahət günü',
'assistant.card.vacay.allowance': 'Məzuniyyət limiti',
'assistant.card.vacay.meter': '{used} istifadə olunub, {planned} planlaşdırılıb, {total} gündən {left} qalıb',
'assistant.card.vacay.meter': '{used} istifadə olunub, {planned} planlaşdırılıb, {total} gündən {remaining} qalıb',
'assistant.card.vacay.weekendBlocked': 'Həftəsonu, planınızda bağlıdır',
'assistant.card.vacay.weekend': 'Həftəsonu günü, məzuniyyət sayılır',
'assistant.card.vacay.publicHoliday': 'Rəsmi bayram, məzuniyyət lazım deyil',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Mais {count} dias de folga este ano',
'assistant.card.vacay.otherDays.one': 'Mais {count} dia de folga este ano',
'assistant.card.vacay.allowance': 'Direito',
'assistant.card.vacay.meter': '{used} usados, {planned} planejados, restam {left} de {total}',
'assistant.card.vacay.meter': '{used} usados, {planned} planejados, restam {remaining} de {total}',
'assistant.card.vacay.weekendBlocked': 'Fim de semana, bloqueado no seu plano',
'assistant.card.vacay.weekend': 'Dia de fim de semana, conta como férias',
'assistant.card.vacay.publicHoliday': 'Feriado, não precisa de folga',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} dies lliures més aquest any',
'assistant.card.vacay.otherDays.one': '{count} dia lliure més aquest any',
'assistant.card.vacay.allowance': 'Dret',
'assistant.card.vacay.meter': '{used} gastats, {planned} previstos, queden {left} de {total}',
'assistant.card.vacay.meter': '{used} gastats, {planned} previstos, queden {remaining} de {total}',
'assistant.card.vacay.weekendBlocked': 'Cap de setmana, bloquejat al teu pla',
'assistant.card.vacay.weekend': 'Dia de cap de setmana, compta com a vacances',
'assistant.card.vacay.publicHoliday': 'Festiu, no cal demanar el dia',
+1 -1
View File
@@ -33,7 +33,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.one': 'Letos ještě {count} den volna',
'assistant.card.vacay.otherDays.few': 'Letos ještě {count} dny volna',
'assistant.card.vacay.allowance': 'Nárok',
'assistant.card.vacay.meter': 'Vyčerpáno {used}, naplánováno {planned}, zbývá {left} z {total}',
'assistant.card.vacay.meter': 'Vyčerpáno {used}, naplánováno {planned}, zbývá {remaining} z {total}',
'assistant.card.vacay.weekendBlocked': 'Víkend, ve tvém plánu zablokovaný',
'assistant.card.vacay.weekend': 'Víkendový den, počítá se jako dovolená',
'assistant.card.vacay.publicHoliday': 'Státní svátek, dovolená není potřeba',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} weitere freie Tage in diesem Jahr',
'assistant.card.vacay.otherDays.one': '{count} weiterer freier Tag in diesem Jahr',
'assistant.card.vacay.allowance': 'Urlaubsanspruch',
'assistant.card.vacay.meter': '{used} genommen, {planned} geplant, {left} übrig von {total}',
'assistant.card.vacay.meter': '{used} genommen, {planned} geplant, {remaining} übrig von {total}',
'assistant.card.vacay.weekendBlocked': 'Wochenende, in deinem Plan gesperrt',
'assistant.card.vacay.weekend': 'Wochenendtag, zählt als Urlaub',
'assistant.card.vacay.publicHoliday': 'Feiertag, kein Urlaub nötig',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} more days off this year',
'assistant.card.vacay.otherDays.one': '{count} more day off this year',
'assistant.card.vacay.allowance': 'Allowance',
'assistant.card.vacay.meter': '{used} used, {planned} planned, {left} left of {total}',
'assistant.card.vacay.meter': '{used} used, {planned} planned, {remaining} left of {total}',
'assistant.card.vacay.weekendBlocked': 'Weekend, blocked in your plan',
'assistant.card.vacay.weekend': 'Weekend day, counts as leave',
'assistant.card.vacay.publicHoliday': 'Public holiday, no leave needed',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} días libres más este año',
'assistant.card.vacay.otherDays.one': '{count} día libre más este año',
'assistant.card.vacay.allowance': 'Derecho',
'assistant.card.vacay.meter': '{used} usados, {planned} previstos, quedan {left} de {total}',
'assistant.card.vacay.meter': '{used} usados, {planned} previstos, quedan {remaining} de {total}',
'assistant.card.vacay.weekendBlocked': 'Fin de semana, bloqueado en tu plan',
'assistant.card.vacay.weekend': 'Día de fin de semana, cuenta como vacaciones',
'assistant.card.vacay.publicHoliday': 'Festivo, no hace falta pedir el día',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Sel aastal veel {count} vaba päeva',
'assistant.card.vacay.otherDays.one': 'Sel aastal veel {count} vaba päev',
'assistant.card.vacay.allowance': 'Puhkusepäevade õigus',
'assistant.card.vacay.meter': '{used} kasutatud, {planned} plaanis, {total} päevast {left} jäänud',
'assistant.card.vacay.meter': '{used} kasutatud, {planned} plaanis, {total} päevast {remaining} jäänud',
'assistant.card.vacay.weekendBlocked': 'Nädalavahetus, sinu plaanis blokeeritud',
'assistant.card.vacay.weekend': 'Nädalavahetuse päev, loetakse puhkuseks',
'assistant.card.vacay.publicHoliday': 'Riigipüha, puhkust pole vaja',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} autres jours de congé cette année',
'assistant.card.vacay.otherDays.one': '{count} autre jour de congé cette année',
'assistant.card.vacay.allowance': 'Droits',
'assistant.card.vacay.meter': '{used} pris, {planned} prévus, {left} restants sur {total}',
'assistant.card.vacay.meter': '{used} pris, {planned} prévus, {remaining} restants sur {total}',
'assistant.card.vacay.weekendBlocked': 'Week-end, bloqué dans votre plan',
'assistant.card.vacay.weekend': 'Jour de week-end, compté comme congé',
'assistant.card.vacay.publicHoliday': 'Jour férié, pas besoin de congé',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Άλλες {count} μέρες άδειας φέτος',
'assistant.card.vacay.otherDays.one': 'Άλλη {count} μέρα άδειας φέτος',
'assistant.card.vacay.allowance': 'Δικαίωμα',
'assistant.card.vacay.meter': '{used} χρησιμοποιήθηκαν, {planned} σχεδιάζονται, απομένουν {left} από {total}',
'assistant.card.vacay.meter': '{used} χρησιμοποιήθηκαν, {planned} σχεδιάζονται, απομένουν {remaining} από {total}',
'assistant.card.vacay.weekendBlocked': 'Σαββατοκύριακο, μπλοκαρισμένο στο πλάνο σου',
'assistant.card.vacay.weekend': 'Μέρα Σαββατοκύριακου, μετράει ως άδεια',
'assistant.card.vacay.publicHoliday': 'Δημόσια αργία, δεν χρειάζεται άδεια',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Idén még {count} szabadnap',
'assistant.card.vacay.otherDays.one': 'Idén még {count} szabadnap',
'assistant.card.vacay.allowance': 'Szabadságkeret',
'assistant.card.vacay.meter': '{used} felhasználva, {planned} tervezve, {total} napból {left} maradt',
'assistant.card.vacay.meter': '{used} felhasználva, {planned} tervezve, {total} napból {remaining} maradt',
'assistant.card.vacay.weekendBlocked': 'Hétvége, a tervedben letiltva',
'assistant.card.vacay.weekend': 'Hétvégi nap, szabadságnak számít',
'assistant.card.vacay.publicHoliday': 'Ünnepnap, nem kell szabadság',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '{count} hari pengganti',
'assistant.card.vacay.otherDays': '{count} hari libur lagi tahun ini',
'assistant.card.vacay.allowance': 'Jatah cuti',
'assistant.card.vacay.meter': '{used} terpakai, {planned} direncanakan, sisa {left} dari {total}',
'assistant.card.vacay.meter': '{used} terpakai, {planned} direncanakan, sisa {remaining} dari {total}',
'assistant.card.vacay.weekendBlocked': 'Akhir pekan, diblokir di rencanamu',
'assistant.card.vacay.weekend': 'Hari akhir pekan, dihitung sebagai cuti',
'assistant.card.vacay.publicHoliday': 'Hari libur nasional, tidak perlu cuti',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': "Altri {count} giorni liberi quest'anno",
'assistant.card.vacay.otherDays.one': "{count} altro giorno libero quest'anno",
'assistant.card.vacay.allowance': 'Disponibilità',
'assistant.card.vacay.meter': '{used} usati, {planned} previsti, {left} rimasti su {total}',
'assistant.card.vacay.meter': '{used} usati, {planned} previsti, {remaining} rimasti su {total}',
'assistant.card.vacay.weekendBlocked': 'Fine settimana, bloccato nel tuo piano',
'assistant.card.vacay.weekend': 'Giorno del fine settimana, conta come ferie',
'assistant.card.vacay.publicHoliday': 'Festività, non servono ferie',
+4 -2
View File
@@ -62,8 +62,10 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.warn.dark_start': '日の出前に出発します({time})',
'assistant.card.tour.warn.dusk': '日没の1時間以内に戻ります',
'assistant.card.tour.warn.legs_failed': '{count}区間を計画できませんでした',
'assistant.card.tour.problem.no_valhalla': 'このサーバーのルート検索は登山道を知らないため、ハイキングを計画できませんでした',
'assistant.card.tour.problem.router_unavailable': 'ルート検索が応答しませんでした。少ししてからもう一度お試しください',
'assistant.card.tour.problem.no_valhalla':
'このサーバーのルート検索は登山道を知らないため、ハイキングを計画できませんでした',
'assistant.card.tour.problem.router_unavailable':
'ルート検索が応答しませんでした。少ししてからもう一度お試しください',
'assistant.card.tour.problem.no_way': 'T{grade}までの道ではこれらの地点を結べません',
'assistant.card.tour.problem.no_way_any': 'これらの地点を結ぶ道がありません',
'assistant.card.tour.problem.point_off_the_way': '地点{point}はどの道からも離れすぎています',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '代休{count}日',
'assistant.card.vacay.otherDays': '今年はほかに{count}日休み',
'assistant.card.vacay.allowance': '付与日数',
'assistant.card.vacay.meter': '使用{used}、予定{planned}、{total}日中残り{left}',
'assistant.card.vacay.meter': '使用{used}、予定{planned}、{total}日中残り{remaining}',
'assistant.card.vacay.weekendBlocked': '週末、プランでブロック中',
'assistant.card.vacay.weekend': '週末、休暇として数える',
'assistant.card.vacay.publicHoliday': '祝日、休暇は不要',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '대체휴무 {count}일',
'assistant.card.vacay.otherDays': '올해 {count}일 더 쉼',
'assistant.card.vacay.allowance': '휴가 일수',
'assistant.card.vacay.meter': '{used} 사용, {planned} 예정, {total} 중 {left} 남음',
'assistant.card.vacay.meter': '{used} 사용, {planned} 예정, {total} 중 {remaining} 남음',
'assistant.card.vacay.weekendBlocked': '주말, 플랜에서 차단됨',
'assistant.card.vacay.weekend': '주말, 휴가로 계산됨',
'assistant.card.vacay.publicHoliday': '공휴일, 휴가 불필요',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Nog {count} vrije dagen dit jaar',
'assistant.card.vacay.otherDays.one': 'Nog {count} vrije dag dit jaar',
'assistant.card.vacay.allowance': 'Vakantierecht',
'assistant.card.vacay.meter': '{used} opgenomen, {planned} gepland, {left} over van {total}',
'assistant.card.vacay.meter': '{used} opgenomen, {planned} gepland, {remaining} over van {total}',
'assistant.card.vacay.weekendBlocked': 'Weekend, geblokkeerd in je plan',
'assistant.card.vacay.weekend': 'Weekenddag, telt als vakantie',
'assistant.card.vacay.publicHoliday': 'Feestdag, geen vakantie nodig',
+1 -1
View File
@@ -40,7 +40,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.few': 'Jeszcze {count} dni wolne w tym roku',
'assistant.card.vacay.otherDays.many': 'Jeszcze {count} dni wolnych w tym roku',
'assistant.card.vacay.allowance': 'Wymiar',
'assistant.card.vacay.meter': 'Wykorzystano {used}, zaplanowano {planned}, zostało {left} z {total}',
'assistant.card.vacay.meter': 'Wykorzystano {used}, zaplanowano {planned}, zostało {remaining} z {total}',
'assistant.card.vacay.weekendBlocked': 'Weekend, zablokowany w Twoim planie',
'assistant.card.vacay.weekend': 'Dzień weekendu, liczy się jako urlop',
'assistant.card.vacay.publicHoliday': 'Święto, urlop niepotrzebny',
+1 -1
View File
@@ -40,7 +40,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.few': 'Ещё {count} дня отдыха в этом году',
'assistant.card.vacay.otherDays.many': 'Ещё {count} дней отдыха в этом году',
'assistant.card.vacay.allowance': 'Право на отпуск',
'assistant.card.vacay.meter': 'Использовано {used}, запланировано {planned}, осталось {left} из {total}',
'assistant.card.vacay.meter': 'Использовано {used}, запланировано {planned}, осталось {remaining} из {total}',
'assistant.card.vacay.weekendBlocked': 'Выходные, заблокированы в вашем плане',
'assistant.card.vacay.weekend': 'Выходной день, считается отпуском',
'assistant.card.vacay.publicHoliday': 'Праздник, отпуск не нужен',
+1 -1
View File
@@ -33,7 +33,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.one': 'Tento rok ešte {count} deň voľna',
'assistant.card.vacay.otherDays.few': 'Tento rok ešte {count} dni voľna',
'assistant.card.vacay.allowance': 'Nárok',
'assistant.card.vacay.meter': 'Vyčerpané {used}, naplánované {planned}, zostáva {left} z {total}',
'assistant.card.vacay.meter': 'Vyčerpané {used}, naplánované {planned}, zostáva {remaining} z {total}',
'assistant.card.vacay.weekendBlocked': 'Víkend, vo tvojom pláne zablokovaný',
'assistant.card.vacay.weekend': 'Víkendový deň, počíta sa ako dovolenka',
'assistant.card.vacay.publicHoliday': 'Štátny sviatok, dovolenka nie je potrebná',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': '{count} lediga dagar till i år',
'assistant.card.vacay.otherDays.one': '{count} ledig dag till i år',
'assistant.card.vacay.allowance': 'Semesterrätt',
'assistant.card.vacay.meter': '{used} uttagna, {planned} planerade, {left} kvar av {total}',
'assistant.card.vacay.meter': '{used} uttagna, {planned} planerade, {remaining} kvar av {total}',
'assistant.card.vacay.weekendBlocked': 'Veckoslut, spärrat i din plan',
'assistant.card.vacay.weekend': 'Veckoslut, räknas som semester',
'assistant.card.vacay.publicHoliday': 'Helgdag, ingen semester behövs',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': 'วันหยุดชดเชย {count} วัน',
'assistant.card.vacay.otherDays': 'ปีนี้หยุดอีก {count} วัน',
'assistant.card.vacay.allowance': 'สิทธิวันลา',
'assistant.card.vacay.meter': 'ใช้ไป {used} วางแผน {planned} เหลือ {left} จาก {total}',
'assistant.card.vacay.meter': 'ใช้ไป {used} วางแผน {planned} เหลือ {remaining} จาก {total}',
'assistant.card.vacay.weekendBlocked': 'วันหยุดสุดสัปดาห์ ถูกบล็อกในแผนของคุณ',
'assistant.card.vacay.weekend': 'วันหยุดสุดสัปดาห์ นับเป็นวันลา',
'assistant.card.vacay.publicHoliday': 'วันหยุดนักขัตฤกษ์ ไม่ต้องลา',
+1 -1
View File
@@ -26,7 +26,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays': 'Bu yıl {count} izin günü daha',
'assistant.card.vacay.otherDays.one': 'Bu yıl {count} izin günü daha',
'assistant.card.vacay.allowance': 'Hakediş',
'assistant.card.vacay.meter': '{used} kullanıldı, {planned} planlandı, {total} günden {left} kaldı',
'assistant.card.vacay.meter': '{used} kullanıldı, {planned} planlandı, {total} günden {remaining} kaldı',
'assistant.card.vacay.weekendBlocked': 'Hafta sonu, planında engelli',
'assistant.card.vacay.weekend': 'Hafta sonu günü, izin sayılır',
'assistant.card.vacay.publicHoliday': 'Resmi tatil, izin gerekmez',
+1 -1
View File
@@ -40,7 +40,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.otherDays.few': 'Ще {count} дні відпочинку цього року',
'assistant.card.vacay.otherDays.many': 'Ще {count} днів відпочинку цього року',
'assistant.card.vacay.allowance': 'Право на відпустку',
'assistant.card.vacay.meter': 'Використано {used}, заплановано {planned}, залишилося {left} з {total}',
'assistant.card.vacay.meter': 'Використано {used}, заплановано {planned}, залишилося {remaining} з {total}',
'assistant.card.vacay.weekendBlocked': 'Вихідні, заблоковані у вашому плані',
'assistant.card.vacay.weekend': 'Вихідний день, рахується як відпустка',
'assistant.card.vacay.publicHoliday': 'Свято, відпустка не потрібна',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '{count} ngày nghỉ bù',
'assistant.card.vacay.otherDays': 'Năm nay còn nghỉ thêm {count} ngày',
'assistant.card.vacay.allowance': 'Số ngày phép',
'assistant.card.vacay.meter': 'Đã dùng {used}, dự kiến {planned}, còn {left} trên {total}',
'assistant.card.vacay.meter': 'Đã dùng {used}, dự kiến {planned}, còn {remaining} trên {total}',
'assistant.card.vacay.weekendBlocked': 'Cuối tuần, bị chặn trong kế hoạch của bạn',
'assistant.card.vacay.weekend': 'Ngày cuối tuần, tính là ngày phép',
'assistant.card.vacay.publicHoliday': 'Ngày lễ, không cần nghỉ phép',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '補休 {count} 天',
'assistant.card.vacay.otherDays': '今年還有 {count} 天休假',
'assistant.card.vacay.allowance': '年假額度',
'assistant.card.vacay.meter': '已用 {used},計畫 {planned},共 {total},剩餘 {left}',
'assistant.card.vacay.meter': '已用 {used},計畫 {planned},共 {total},剩餘 {remaining}',
'assistant.card.vacay.weekendBlocked': '週末,在你的計畫中已封鎖',
'assistant.card.vacay.weekend': '週末,計入假期',
'assistant.card.vacay.publicHoliday': '公共假日,無需請假',
+1 -1
View File
@@ -19,7 +19,7 @@ const assistantVacay: TranslationStrings = {
'assistant.card.vacay.compDays': '调休 {count} 天',
'assistant.card.vacay.otherDays': '今年还有 {count} 天休假',
'assistant.card.vacay.allowance': '年假额度',
'assistant.card.vacay.meter': '已用 {used},计划 {planned},共 {total},剩余 {left}',
'assistant.card.vacay.meter': '已用 {used},计划 {planned},共 {total},剩余 {remaining}',
'assistant.card.vacay.weekendBlocked': '周末,在你的计划中已屏蔽',
'assistant.card.vacay.weekend': '周末,计入假期',
'assistant.card.vacay.publicHoliday': '公共假日,无需请假',
+4 -1
View File
@@ -50,7 +50,10 @@ export function cheapestEnergy(tariffs: ChargingInfo['tariffs']): ChargingEnergy
.map((component) => ({ price: component.price, currency: tariff.currency })),
);
if (new Set(energy.map((component) => component.currency)).size !== 1) return null;
return energy.reduce<ChargingEnergyPrice | null>((best, item) => (!best || item.price < best.price ? item : best), null);
return energy.reduce<ChargingEnergyPrice | null>(
(best, item) => (!best || item.price < best.price ? item : best),
null,
);
}
/**