chore(ci): give the server test typecheck room, format new client files

The server's src and tests together are past 570,000 lines and the test
typecheck needs about 4.3 GB, over Node's default heap; it now gets 8 GB like
the test jobs. Forty new client files are formatted, and the react-hooks/refs
baseline drops by the one warning a split hook removed.
This commit is contained in:
Maurice
2026-10-20 18:39:58 +02:00
parent baba2de344
commit 204ee2ef86
42 changed files with 176 additions and 103 deletions
+4 -1
View File
@@ -261,8 +261,11 @@ jobs:
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it.
# catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
+1 -1
View File
@@ -8,7 +8,7 @@
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 559,
"react-hooks/refs": 558,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
+1 -2
View File
@@ -7,6 +7,5 @@ export const tourSkiApi = {
tripId: number | string,
placeId: number | string,
body: TourSkiClassificationRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
};
@@ -5,8 +5,8 @@
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
@@ -5,9 +5,9 @@
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { buildSettings } from '../../../../tests/helpers/factories';
import { useSettingsStore } from '../../../store/settingsStore';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import type { TourActivityStats } from './useTourActivityStats';
@@ -25,7 +25,8 @@ export function ReadStatusNotice({
{messages.loading}
</span>
);
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{messages.offline}</TourNotice>;
if (status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{messages.offline}</TourNotice>;
if (status !== 'failed') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
@@ -52,8 +52,8 @@ import { TourGpxExportButton } from './TourGpxExportButton';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/planComparison';
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { TourPackingSection } from './packing/TourPackingSection';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { RunHeadPills } from './run/RunHeadPills';
@@ -236,9 +236,7 @@ export default function TourActivityDialog({
variant="primary"
data-testid="tour-activity-confirm"
disabled={!assignment.ready || state.busy || importing}
icon={
state.busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} strokeWidth={2} />
}
icon={state.busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} strokeWidth={2} />}
onClick={confirm}
>
{t('tours.activity.confirm')}
@@ -120,7 +120,17 @@ describe('TourActivitySection (ACT-01)', () => {
expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]);
hook.state = state({ assigning: {}, removing: activity() });
rerender(<TourActivitySection tripId={1} tour={tour} days={days} assignments={assignments} canEdit plan={plan} planColor="plan-paint" />);
rerender(
<TourActivitySection
tripId={1}
tour={tour}
days={days}
assignments={assignments}
canEdit
plan={plan}
planColor="plan-paint"
/>
);
expect(screen.getByTestId('tour-activity-dialog')).toBeInTheDocument();
expect(screen.getByText('tours.activity.removeTitle')).toBeInTheDocument();
});
@@ -193,6 +203,9 @@ describe('TourActivitySection (ACT-01)', () => {
expect(screen.queryByRole('button', { name: /tours.activity.compare.(show|hide)/ })).toBeNull();
act(() => useTourActivityCompareStore.getState().setMapAvailable(true));
expect(screen.getByRole('button', { name: /tours.activity.compare.show/ })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: /tours.activity.compare.show/ })).toHaveAttribute(
'aria-pressed',
'false'
);
});
});
@@ -117,7 +117,9 @@ describe('useActivityCompareMap (ACT-02)', () => {
});
it('FE-ACT-COMPARE-MAP-006: tells the store whether it can compare, and ends a comparison where it no longer can or is gone', () => {
const { rerender, unmount } = renderHook((props: typeof input) => useActivityCompareMap(props), { initialProps: input });
const { rerender, unmount } = renderHook((props: typeof input) => useActivityCompareMap(props), {
initialProps: input,
});
expect(useTourActivityCompareStore.getState().mapAvailable).toBe(true);
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
@@ -183,7 +183,12 @@ describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-014: lines of different paints each take their own, an unknown key the accent (MAP-03)', () => {
const drawn = withRouteHighlight(
{ id: 'sac', lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)], paint: 'gravel', paints: ['way', 'nope'] },
{
id: 'sac',
lines: [LINE.slice(0, 2), LINE.slice(1, 3), LINE.slice(2)],
paint: 'gravel',
paints: ['way', 'nope'],
},
[LINE],
null,
null,
@@ -1,5 +1,5 @@
import { TOUR_TRAIL_GRADE_MAX_WAYS } from '@trek/shared';
import { act, renderHook } from '@testing-library/react';
import { TOUR_TRAIL_GRADE_MAX_WAYS } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
@@ -34,9 +34,11 @@ describe('GPX import choices', () => {
tour_type: 'ski',
ski_discipline: 'touring',
});
expect(
importFields({ ...base, type: 'ski', ski: { discipline: 'cross_country', technique: 'unknown' } })
).toEqual({ tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'unknown' });
expect(importFields({ ...base, type: 'ski', ski: { discipline: 'cross_country', technique: 'unknown' } })).toEqual({
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'unknown',
});
});
it('FE-TOUR-GPX-CHOICES-002: holds the import back while a paddle tour or a ski track lacks what it must name', () => {
@@ -1,5 +1,5 @@
import type { TourTrailGrade } from '@trek/shared';
import { act, renderHook } from '@testing-library/react';
import type { TourTrailGrade } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWayEdge, RouteWays } from '../bike/routeWays';
@@ -13,16 +13,7 @@ import type { HikePaintKey } from './hikePaint';
*/
type HikeSurfaceClass =
| 'paved'
| 'sett'
| 'compacted'
| 'gravel'
| 'ground'
| 'grass'
| 'rock'
| 'wood'
| 'unpaved'
| 'other';
'paved' | 'sett' | 'compacted' | 'gravel' | 'ground' | 'grass' | 'rock' | 'wood' | 'unpaved' | 'other';
type HikeWayClass = 'path' | 'footway' | 'steps' | 'track' | 'cycleway' | 'quietRoad' | 'road' | 'viaFerrata' | 'other';
@@ -1,16 +1,16 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { TourDayRowIcon } from '../TourDayRowFacts';
import { tourDayRowIcon } from '../tourDayRowIcon';
import { movingTimeLabel } from '../tourFacts';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { useShapingClick } from '../planner/useShapingClick';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { WaypointLegRows } from '../planner/WaypointLegRows';
import { TourDayRowIcon } from '../TourDayRowFacts';
import { tourDayRowIcon } from '../tourDayRowIcon';
import { movingTimeLabel } from '../tourFacts';
import { HorseLegRow } from './HorseLegRow';
import { Horseshoe } from './HorseshoeIcon';
import type { HorseSectionLeg } from './horseSection';
import { Horseshoe } from './HorseshoeIcon';
// FE-HORSE-LEG-001 to FE-HORSE-LEG-005
@@ -6,8 +6,8 @@ import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { lineLengthM } from '../planner/tourFixedLegs';
import { ACCESS_TONE_DOT, accessText, accessToneOf } from './horseAccess';
import { Horseshoe } from './HorseshoeIcon';
import type { HorseSectionLeg } from './horseSection';
import { Horseshoe } from './HorseshoeIcon';
/**
* HOR-01: a section of a horse route the ride follows, as a badge on its leg
@@ -132,9 +132,24 @@ describe('horse route sections on a ride (HOR-01)', () => {
expect(accessToneOf(undefined)).toBeNull();
expect(accessToneOf([])).toBeNull();
expect(accessToneOf([{ value: 'yes', length_m: 10 }])).toBe('open');
expect(accessToneOf([{ value: 'yes', length_m: 10 }, { value: 'unknown', length_m: 1 }])).toBe('unknown');
expect(accessToneOf([{ value: 'unknown', length_m: 10 }, { value: 'destination', length_m: 1 }])).toBe('limited');
expect(accessToneOf([{ value: 'designated', length_m: 10 }, { value: 'private', length_m: 1 }])).toBe('closed');
expect(
accessToneOf([
{ value: 'yes', length_m: 10 },
{ value: 'unknown', length_m: 1 },
])
).toBe('unknown');
expect(
accessToneOf([
{ value: 'unknown', length_m: 10 },
{ value: 'destination', length_m: 1 },
])
).toBe('limited');
expect(
accessToneOf([
{ value: 'designated', length_m: 10 },
{ value: 'private', length_m: 1 },
])
).toBe('closed');
});
it('FE-HORSE-MODEL-009: spells the tags out with the metres each covers', () => {
@@ -1,9 +1,9 @@
import type { TourFixedLeg } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { tourMapLayersFor } from '../tourMapLayers';
import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { toggleFreeLeg } from '../planner/tourRouteActions';
import { rideTour, routeWalkingTour } from '../planner/tourRouting';
import { tourMapLayersFor } from '../tourMapLayers';
// FE-HORSE-ROUTE-001 to FE-HORSE-ROUTE-006
@@ -22,7 +22,16 @@ const GEAR: TourPackingLink = { category: 'Hiking gear', linked_at: '2026-10-20
let nextId = 1;
function item(name: string, category: string | null, over: Partial<PackingItem> = {}): PackingItem {
return { id: nextId++, trip_id: 5, name, category, checked: 0, sort_order: nextId, quantity: 1, ...over } as PackingItem;
return {
id: nextId++,
trip_id: 5,
name,
category,
checked: 0,
sort_order: nextId,
quantity: 1,
...over,
} as PackingItem;
}
const ITEMS = [
@@ -6,8 +6,8 @@ import type { DistanceUnit } from '../../../types';
import { formatDistance, getDistanceUnitLabel } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { fs } from '../../shared/DialogShell';
import { INPUT, LABEL, PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourNotice } from '../tourParts';
import { MapCard, MapCardActions, MapCornerOverlay, StopDisc, VariantFigures, WayChoice } from './mapCardParts';
@@ -7,9 +7,9 @@ import { INPUT, LABEL } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
import { HikingPreferencesField } from './HikingPreferencesField';
import { WinterActivityField } from './WinterActivityField';
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
import { WinterActivityField } from './WinterActivityField';
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
@@ -5,9 +5,9 @@ import { useSettingsStore } from '../../../store/settingsStore';
import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { HorseLegRow } from '../horse/HorseLegRow';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { HorseLegRow } from '../horse/HorseLegRow';
import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
@@ -102,9 +102,9 @@ function hasNeighbour(grid: Map<string, Sample[]>, sample: Sample, counts: (othe
const cy = cellOf(sample.y);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const near = grid.get(`${cx + dx}:${cy + dy}`)?.some(
(other) => Math.hypot(other.x - sample.x, other.y - sample.y) <= SAME_GROUND_M && counts(other)
);
const near = grid
.get(`${cx + dx}:${cy + dy}`)
?.some((other) => Math.hypot(other.x - sample.x, other.y - sample.y) <= SAME_GROUND_M && counts(other));
if (near) return true;
}
}
@@ -10,9 +10,9 @@ const { valhallaRun, enrichTourElevations } = vi.hoisted(() => ({
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun }));
vi.mock('./tourRouting', () => ({ enrichTourElevations }));
import { fitsWish, type RoundRequest } from './roundHikes';
import { spotToward } from './roundHikeGeometry';
import { findRoundHikes, MAX_ROUND_ASKS, type RoundSearchReport } from './roundHikeRouting';
import { fitsWish, type RoundRequest } from './roundHikes';
type Spot = { lat: number; lng: number };
@@ -3,8 +3,8 @@ import { valhallaRun } from '../../Map/valhallaRoute';
import { figuresOf } from './legVariants';
import { loopSpots, retracedShare, sharedShare, STRAIGHT_PER_RADIUS_M } from './roundHikeGeometry';
import { fitsWish, ROUND_KEYS, type RoundCandidate, type RoundRequest } from './roundHikes';
import { enrichTourElevations } from './tourRouting';
import { MAX_TOUR_SNAP_M } from './tourRouteOutcome';
import { enrichTourElevations } from './tourRouting';
type Spot = { lat: number; lng: number };
@@ -4,8 +4,8 @@ import { convertDistance } from '../../../utils/units';
import type { CostingOptions } from '../../Map/valhallaRoute';
import { OTHER_WAY_KEYS, type LegVariant } from './legVariants';
import { spotToward } from './roundHikeGeometry';
import { closeLoop } from './tourRouteActions';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import { closeLoop } from './tourRouteActions';
/**
* RTE-03: round hikes of about a wished length out of a start point, as a
@@ -139,9 +139,13 @@ export function roundPlanOf(
newId: () => string
): TourPlannerWaypoint[] {
const { fixedLeg: _leg, lock: _lock, carryNext: _carry, auto: _auto, shaping: _shaping, ...own } = start;
const steering = via.map(
({ lat, lng }): TourPlannerWaypoint => ({ id: newId(), lat, lng, role: 'via', shaping: true })
);
const steering = via.map(({ lat, lng }): TourPlannerWaypoint => ({
id: newId(),
lat,
lng,
role: 'via',
shaping: true,
}));
return closeLoop([{ ...own, role: 'start' }, ...steering], newId());
}
@@ -25,7 +25,8 @@ function alongLine(
const t =
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
if (distance < best.distance) best = { at: travelled + length * t, index: i, vertex: t < 0.5 ? i : i + 1, distance };
if (distance < best.distance)
best = { at: travelled + length * t, index: i, vertex: t < 0.5 ? i : i + 1, distance };
}
travelled += length;
}
@@ -87,10 +87,7 @@ describe('useTourPlanner, the winter label of a hike (HIK-01)', () => {
await act(async () => {
await result.current.save();
});
expect(createTour).toHaveBeenCalledWith(
61,
expect.objectContaining({ tour_type: 'hike', winter_activity: null })
);
expect(createTour).toHaveBeenCalledWith(61, expect.objectContaining({ tour_type: 'hike', winter_activity: null }));
});
it('FE-TOUR-WINTER-002: the label plans nothing again, is no step of the history and is kept in the draft', async () => {
@@ -179,10 +176,7 @@ describe('useTourPlanner, the winter label of a hike (HIK-01)', () => {
await act(async () => {
await result.current.save();
});
expect(createTour).toHaveBeenCalledWith(
65,
expect.objectContaining({ tour_type: 'walk', winter_activity: null })
);
expect(createTour).toHaveBeenCalledWith(65, expect.objectContaining({ tour_type: 'walk', winter_activity: null }));
});
it('FE-TOUR-WINTER-006: keeps the label the switch brings back with the tour, winter hiking for the next one', async () => {
@@ -1,9 +1,4 @@
import {
MAX_RUN_PACE_SECONDS_PER_KM,
MIN_RUN_PACE_SECONDS_PER_KM,
RUN_PROFILES,
type RunProfile,
} from '@trek/shared';
import { MAX_RUN_PACE_SECONDS_PER_KM, MIN_RUN_PACE_SECONDS_PER_KM, RUN_PROFILES, type RunProfile } from '@trek/shared';
import { Clock, Gauge, Info, Ruler } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
+1 -1
View File
@@ -20,7 +20,7 @@ export function skiChoiceMissing(choice: SkiChoice): 'discipline' | 'technique'
/** The fields a complete choice is sent as; null while something is missing. */
export function skiFieldsOf(
choice: SkiChoice,
choice: SkiChoice
): Pick<TourSkiClassificationRequest, 'ski_discipline' | 'ski_technique'> | null {
if (choice.discipline === null || skiChoiceMissing(choice)) return null;
return choice.discipline === 'cross_country'
@@ -29,8 +29,7 @@ export function useSkiClassification({ tripId, planner, tours, canEdit, onSaved
const [draft, setDraft] = useState<SkiChoice | null>(null);
const [saving, setSaving] = useState(false);
const viewed = planner.mode.type === 'view-gpx' ? planner.mode.tour : null;
const tour =
viewed?.tour_type === 'ski' ? (tours.find((item) => item.place_id === viewed.place_id) ?? viewed) : null;
const tour = viewed?.tour_type === 'ski' ? (tours.find((item) => item.place_id === viewed.place_id) ?? viewed) : null;
const saved = useMemo(() => (tour ? skiChoiceOf(tour) : NO_SKI_CHOICE), [tour]);
const open = useCallback(() => {
@@ -18,7 +18,11 @@ interface StageDayPickerProps {
}
/** The days a stage can go on, the one it is on checked, and a way back to none. */
function dayItems({ stageKey, days, stageDays }: StageDayPickerProps, label: (day: Day, index: number) => string, noDay: string) {
function dayItems(
{ stageKey, days, stageDays }: StageDayPickerProps,
label: (day: Day, index: number) => string,
noDay: string
) {
const current = stageDays.dayOf(stageKey);
const items: MenuItems = days.map((day, index) => ({
label: label(day, index),
@@ -27,7 +31,11 @@ function dayItems({ stageKey, days, stageDays }: StageDayPickerProps, label: (da
onClick: () => void stageDays.setDay(stageKey, day.id),
}));
if (current === null) return items;
return [...items, { divider: true }, { label: noDay, icon: CalendarX2, onClick: () => void stageDays.setDay(stageKey, null) }];
return [
...items,
{ divider: true },
{ label: noDay, icon: CalendarX2, onClick: () => void stageDays.setDay(stageKey, null) },
];
}
/**
@@ -55,7 +63,11 @@ export function StageDayPicker(props: StageDayPickerProps) {
menu.close();
return;
}
menu.open(event, dayItems(props, (option, at) => dayMenuLabel(option, at, t, locale), t('tours.stages.noDay')), true);
menu.open(
event,
dayItems(props, (option, at) => dayMenuLabel(option, at, t, locale), t('tours.stages.noDay')),
true
);
};
const look = day
@@ -86,7 +98,9 @@ export function StageDayPicker(props: StageDayPickerProps) {
<span className={day ? 'font-geist tabular-nums' : undefined}>
{day ? t('dayplan.dayN', { n: index + 1 }) : t('tours.stages.noDay')}
</span>
{interactive && <ChevronDown size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />}
{interactive && (
<ChevronDown size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
)}
</button>
</Tooltip>
<ContextMenu menu={interactive ? menu.menu : null} onClose={menu.close} />
@@ -1,7 +1,11 @@
import { paddleSpeedKmhSchema, recommendedPaddleSpeedKmh, type TourListItem, type TourWaypoint } from '@trek/shared';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { openedTourFields } from '../planner/tourPlannerFacts';
import { normalizePlannerWaypoints, plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import {
normalizePlannerWaypoints,
plannerWaypointsOf,
type TourPlannerWaypoint,
} from '../planner/tourPlannerWaypoints';
import { legLengthsAlong, tourStagesOf, type PaddleStageInputs, type TourStage } from './tourStages';
/** MDT-02: a stage of a saved tour, with the key of the stop it starts at, by which a day holds it. */
@@ -21,7 +25,11 @@ export function savedStagePoints(tour: TourListItem, saved: readonly TourWaypoin
* the paddler chose, else the boat's suggestion, and each leg as long as the
* saved line runs it. Null for any other type.
*/
function paddleInputsOf(tour: TourListItem, points: readonly TourPlannerWaypoint[], line: ReadonlyArray<readonly number[]>): PaddleStageInputs | null {
function paddleInputsOf(
tour: TourListItem,
points: readonly TourPlannerWaypoint[],
line: ReadonlyArray<readonly number[]>
): PaddleStageInputs | null {
if (tour.tour_type !== 'kayak') return null;
const chosen = tour.paddle_speed_kmh ?? null;
return {
@@ -132,9 +132,7 @@ describe('tourStages', () => {
it('FE-TOUR-STAGE-MODEL-013: a run stage is its distance at the pace, a ride on horseback has no time (RUN-01, HOR-01)', () => {
const run = tourStagesOf(input(withEnds('b'), { tourType: 'run', runPaceSecondsPerKm: 360 }));
expect(run.map((stage) => stage.minutes)).toEqual(
run.map((stage) => runMinutesAtPace(stage.distanceKm ?? 0, 360))
);
expect(run.map((stage) => stage.minutes)).toEqual(run.map((stage) => runMinutesAtPace(stage.distanceKm ?? 0, 360)));
// The router's walking time never stands in, neither without a pace nor with one out of bounds.
const noPace = tourStagesOf(input(withEnds('b'), { tourType: 'run' }));
expect(noPace.map((stage) => stage.minutes)).toEqual([null, null]);
@@ -81,7 +81,12 @@ function line(color: string, coordinates: [number, number][]): TrackData['featur
/** A line of the planner, [lat, lng] each, as the terrain's [lng, lat]; null for one too short to draw. */
function plannerLine(color: string, route: [number, number][]): TrackData['features'][number] | null {
return route.length > 1 ? line(color, route.map(([lat, lng]) => [lng, lat] as [number, number])) : null;
return route.length > 1
? line(
color,
route.map(([lat, lng]) => [lng, lat] as [number, number])
)
: null;
}
/** A line laid over the track in its own colour, [lat, lng] each. */
@@ -2,8 +2,8 @@ import { CableCar, ExternalLink, Info, ZoomIn } from 'lucide-react';
import { useEffect } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { LABEL, PopoverListPanel } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { usePopoverList } from '../../shared/usePopoverList';
import { TourNotice } from '../tourParts';
@@ -63,7 +63,8 @@ function RunBand({ colour }: { colour: string }) {
/** A trail as a line in its colour, or as a row of dots where the map dots it. */
function WayLine({ colour, dotted = false }: { colour: string; dotted?: boolean }) {
if (!dotted) return <span aria-hidden className="h-[3px] w-6 flex-none rounded-full" style={{ background: colour }} />;
if (!dotted)
return <span aria-hidden className="h-[3px] w-6 flex-none rounded-full" style={{ background: colour }} />;
return (
<span aria-hidden className="flex w-6 flex-none items-center justify-between px-px">
{[0, 1, 2].map((dot) => (
@@ -92,7 +93,7 @@ function PisteKeyBody({ belowMinZoom }: { belowMinZoom: boolean }) {
</div>
{belowMinZoom && (
<p
className="flex items-center gap-1.5 rounded-lg bg-accent-soft px-2 py-1.5 font-medium text-accent"
className="bg-accent-soft flex items-center gap-1.5 rounded-lg px-2 py-1.5 font-medium text-accent"
style={fs(11)}
data-testid="piste-key-zoom-in"
>
+6 -2
View File
@@ -93,8 +93,12 @@ describe('cacheTourAnswer', () => {
it('CT-004: a packing link answer is kept, null lets it go, and an answer without one keeps the cached one', async () => {
const link = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
expect(await cacheTourAnswer(3, { tour: tour(), waypoints, packing_link: link })).toMatchObject({ packing_link: link });
expect(await cacheTourAnswer(3, { tour: tour({ name: 'Renamed' }), waypoints })).toMatchObject({ packing_link: link });
expect(await cacheTourAnswer(3, { tour: tour(), waypoints, packing_link: link })).toMatchObject({
packing_link: link,
});
expect(await cacheTourAnswer(3, { tour: tour({ name: 'Renamed' }), waypoints })).toMatchObject({
packing_link: link,
});
expect((await cacheTourAnswer(3, { tour: tour(), waypoints, packing_link: null })).packing_link).toBeNull();
const fresh = await cacheTourAnswer(4, { tour: tour({ place_id: 41 }), waypoints });
expect(fresh).not.toHaveProperty('packing_link');
+15 -13
View File
@@ -1,18 +1,20 @@
import type { useMapLocationSearch } from './useMapLocationSearch'
import type { useTourWorkspace } from './useTourWorkspace'
import type { useMapLocationSearch } from './useMapLocationSearch';
import type { useTourWorkspace } from './useTourWorkspace';
// The planner page hook's pure helpers: what the Tours map draws and frames.
// They live apart so the page hook's one object-literal return stays its view model.
type Workspace = ReturnType<typeof useTourWorkspace>
type TourPlanner = Workspace['tourPlanner']
type Workspace = ReturnType<typeof useTourWorkspace>;
type TourPlanner = Workspace['tourPlanner'];
/** PAD-05B: the paddle line in view, a recorded paddle track's or the paddle tour's being planned; none for any other. */
export function paddleLineOf(tourPlanner: TourPlanner) {
if (tourPlanner.mode.type === 'view-gpx') {
return tourPlanner.mode.tour.tour_type === 'kayak' ? tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? null : null
return tourPlanner.mode.tour.tour_type === 'kayak'
? (tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? null)
: null;
}
return tourPlanner.isPaddle ? tourPlanner.route : null
return tourPlanner.isPaddle ? tourPlanner.route : null;
}
/**
@@ -21,21 +23,21 @@ export function paddleLineOf(tourPlanner: TourPlanner) {
* round hikes ask the map for while they are looked for and shown.
*/
export function offeredWaysOf(tourPlanner: TourPlanner, offering: boolean) {
const { comparison, focus: variantFocus } = tourPlanner.legVariants
const { view: rounds, focus: roundFocus, frame: roundFrame } = tourPlanner.roundHikes
const { comparison, focus: variantFocus } = tourPlanner.legVariants;
const { view: rounds, focus: roundFocus, frame: roundFrame } = tourPlanner.roundHikes;
return {
ways: offering ? comparison?.variants ?? rounds?.candidates ?? null : null,
ways: offering ? (comparison?.variants ?? rounds?.candidates ?? null) : null,
focus: comparison ? variantFocus : roundFocus,
frame: offering ? roundFrame : null,
}
};
}
/** What frames the map: a picked wanderer trail itself, else round hikes being framed, else the planner. */
export function mapFocusKeyOf(
wandererBrowser: Workspace['wandererBrowser'],
frame: ReturnType<typeof offeredWaysOf>['frame'],
plannerKey: ReturnType<typeof useMapLocationSearch>['mapFocusKey'],
plannerKey: ReturnType<typeof useMapLocationSearch>['mapFocusKey']
) {
if (wandererBrowser.open && wandererBrowser.selected) return `wanderer:${wandererBrowser.selected.id}`
return frame?.key ?? plannerKey
if (wandererBrowser.open && wandererBrowser.selected) return `wanderer:${wandererBrowser.selected.id}`;
return frame?.key ?? plannerKey;
}
+5 -1
View File
@@ -35,7 +35,11 @@ function tour(overrides: Partial<TourListItem> = {}): TourListItem {
}
const gear: TourPackingLink = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
const answer = (packing_link: TourPackingLink | null): TourDetailResponse => ({ tour: tour(), waypoints: [], packing_link });
const answer = (packing_link: TourPackingLink | null): TourDetailResponse => ({
tour: tour(),
waypoints: [],
packing_link,
});
const cachedLink = async (placeId = 40) => (await offlineDb.tours.get(placeId))?.packing_link;
beforeEach(async () => {
+8 -3
View File
@@ -12,9 +12,14 @@ export const toursHandlers = [
visits: [],
})),
// P2D-XD-01: a tour's detail reads its packing link; a tour points at none unless a test says so.
contractHandler('get', '/api/trips/:id/tours/:placeId/packing-link', { response: tourPackingLinkResponseSchema }, () => ({
packing_link: null,
})),
contractHandler(
'get',
'/api/trips/:id/tours/:placeId/packing-link',
{ response: tourPackingLinkResponseSchema },
() => ({
packing_link: null,
})
),
// WAN-02: the wanderer watch is off and leaves no hint on a tour unless a test says so.
contractHandler('get', '/api/integrations/wanderer/watch', { response: wandererWatchSchema }, () => ({
enabled: false,
+2 -1
View File
@@ -51,7 +51,8 @@ export const UNRESET_STORES: Record<string, string> = {
useJourneyStore: 'reset in the journey tests themselves; importing it here pulls in a circular import',
useBackgroundTasksStore: NOT_YET,
useCollectionStore: NOT_YET,
useCollectionTourStore: 'its own tests put it back with clear(); it holds one open collection and nothing else reads it',
useCollectionTourStore:
'its own tests put it back with clear(); it holds one open collection and nothing else reads it',
useDocSyncOfferStore: NOT_YET,
useHelpStore: NOT_YET,
usePluginStore: NOT_YET,