mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +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]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'عطلة رسمية، لا حاجة لإجازة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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': 'Δημόσια αργία, δεν χρειάζεται άδεια',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}はどの道からも離れすぎています',
|
||||
|
||||
@@ -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': '祝日、休暇は不要',
|
||||
|
||||
@@ -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': '공휴일, 휴가 불필요',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Праздник, отпуск не нужен',
|
||||
|
||||
@@ -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á',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'วันหยุดนักขัตฤกษ์ ไม่ต้องลา',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Свято, відпустка не потрібна',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '公共假日,無需請假',
|
||||
|
||||
@@ -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': '公共假日,无需请假',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user