refactor(tours): move non-component exports out of component files and split long hooks

Clears the client lint ratchet: helpers move to sibling .ts files for react-refresh, complex render paths get small helpers, floating promises are voided, the stage rail lets a stale request go during render, and the collections page reads the active list's tours and rights in ActiveCollectionTours. StageNumber is no longer exported.
This commit is contained in:
Maurice
2026-10-20 17:51:28 +02:00
parent 2b8d2e790f
commit b460cfd9a7
65 changed files with 633 additions and 436 deletions
@@ -6,19 +6,10 @@ import type { DistanceUnit } from '../../../types';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../../Tours/tourParts';
import { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import { splitMeasure } from './tourActivityStatsModel';
import {
ActivityFilters,
ActivityStatusLine,
BasisBadges,
distanceReading,
heightReading,
typeIcon,
typeLabel,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
@@ -181,9 +172,7 @@ function Strip({ stats, summary, onOpen }: StripProps) {
{counted ? (
<>
<Hero summary={summary} unit={unit} />
{ascent && (
<Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />
)}
{ascent && <Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />}
{descent && (
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
)}
@@ -4,10 +4,11 @@ import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { EntryTile, MeasurePills, useDistanceUnit } from './tourActivityParts';
import { EntryTile, MeasurePills } from './tourActivityParts';
import { useDistanceUnit } from './tourActivityReading';
const ROW_BADGE_ICON = 'flex-none text-content-faint';
@@ -54,7 +55,7 @@ function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
<Tooltip label={t('tours.stats.openTrip')}>
<button
type="button"
onClick={() => navigate(`/trips/${entry.trip.id}`)}
onClick={() => void navigate(`/trips/${entry.trip.id}`)}
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<WhiteBadge
@@ -1,6 +1,6 @@
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
import { Footprints } from 'lucide-react';
import { useId } from 'react';
import { createElement, useId } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import {
@@ -16,18 +16,8 @@ import {
} from '../../shared/DialogShell';
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
import { TourActivityEntryRow } from './TourActivityEntryRow';
import {
ActivityFilters,
ActivityStatusLine,
BasisBadges,
distanceReading,
heightReading,
MeasurePills,
partialHint,
typeIcon,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
import { ActivityFilters, ActivityStatusLine, BasisBadges, MeasurePills, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -52,14 +42,13 @@ function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translat
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const Icon = typeIcon(type);
const distance = distanceReading(totals, unit);
const ascent = heightReading(totals.ascentM, unit);
return (
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
<span className="relative flex-none">
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
<Icon size={14} strokeWidth={1.9} />
{createElement(typeIcon(type), { size: 14, strokeWidth: 1.9 })}
</span>
<span
aria-hidden="true"
@@ -1,4 +1,4 @@
import type { TourActivityBasis, TourActivityStatsEntry, TourActivityTotals } from '@trek/shared';
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
import {
AlertTriangle,
ArrowUp,
@@ -6,33 +6,22 @@ import {
CalendarDays,
CalendarRange,
Footprints,
Layers,
Loader2,
Ruler,
WifiOff,
type LucideIcon,
} from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { periodYear, sumReading, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import { typeIcon, typeLabel, type Translate } from './tourActivityReading';
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The distance unit the user reads in. */
export function useDistanceUnit(): DistanceUnit {
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
}
/** What a period is called in the filter. */
function periodLabel(period: ActivityPeriod, t: Translate): string {
if (period === 'all') return t('tours.stats.period.all');
@@ -41,16 +30,6 @@ function periodLabel(period: ActivityPeriod, t: Translate): string {
return String(periodYear(period));
}
/** What a type is called in the filter. */
export function typeLabel(type: ActivityType, t: Translate): string {
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
}
/** The icon a type is drawn with; every type at once is a stack. */
export function typeIcon(type: ActivityType): LucideIcon {
return type === 'all' ? Layers : tourDayRowIcon(type);
}
/** The period and type filters, as head band pills or quiet ones in a panel. */
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
const { t } = useTranslation();
@@ -85,21 +64,6 @@ export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityS
);
}
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
}
/** A height sum as said, the same way. */
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
return sumReading(sum, (metres) => formatElevation(metres, unit));
}
/** How a short sum explains itself: known for some of the tours only. */
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
}
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
export function MeasurePills({
distance,
@@ -210,12 +174,11 @@ export function UncountedBadges({
*/
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
const { t } = useTranslation();
const Icon = tourDayRowIcon(entry.tour_type);
return (
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
<Icon size={15} strokeWidth={1.9} />
{createElement(tourDayRowIcon(entry.tour_type), { size: 15, strokeWidth: 1.9 })}
</span>
{entry.basis === 'track' && (
<span
@@ -0,0 +1,39 @@
import type { TourActivityTotals } from '@trek/shared';
import { Layers, type LucideIcon } from 'lucide-react';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { sumReading, type ActivityType } from './tourActivityStatsModel';
export type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The distance unit the user reads in. */
export function useDistanceUnit(): DistanceUnit {
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
}
/** What a type is called in the filter. */
export function typeLabel(type: ActivityType, t: Translate): string {
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
}
/** The icon a type is drawn with; every type at once is a stack. */
export function typeIcon(type: ActivityType): LucideIcon {
return type === 'all' ? Layers : tourDayRowIcon(type);
}
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
}
/** A height sum as said, the same way. */
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
return sumReading(sum, (metres) => formatElevation(metres, unit));
}
/** How a short sum explains itself: known for some of the tours only. */
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
}
@@ -6,8 +6,9 @@ import '../../styles/fadeEnd.css';
import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { stageNumberOf } from '../Tours/stages/stageNames';
import { TourStageDayPills } from '../Tours/stages/TourStageDayPills';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { stageNumberOf, TourStageDayPills } from '../Tours/stages/TourStageDayPills';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
import { WalkProgressPill } from '../Tours/walk/WalkStations';
@@ -125,7 +126,11 @@ export function DayPlanPlaceSubline({
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour ? (
<DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} />
) : (
tourTimes && <PlannedTourPills tourTimes={tourTimes} />
)}
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { hintWhen } from '../Tours/wanderer/sourceHintParts';
import { hintWhen } from '../Tours/wanderer/hintWhen';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -92,7 +92,7 @@ export function useWandererWatch(refreshKey: string) {
const openTour = useCallback(
(hint: WandererSourceHintItem) => {
setHintsOpen(false);
navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`);
void navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`);
},
[navigate]
);
@@ -6,8 +6,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
import { formatDifference } from './compareValueText';
import type { PlanValues } from './planComparison';
import TourActivityCompare from './TourActivityCompare';
const plan: PlanValues = {
distanceKm: 2,
@@ -3,14 +3,14 @@ import { Equal, Map as MapIcon, TrendingDown, TrendingUp } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { formatDistance } from '../../../utils/units';
import { BOX, Eyebrow } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourFactFields, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import { formatDifference, formatValue } from './compareValueText';
import {
compareRows,
OFF_PLAN_M,
@@ -29,19 +29,6 @@ const ROW_LABELS: Record<CompareKey, string> = {
time: 'tours.activity.compare.time',
};
function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
if (key === 'distance') return formatDistance(value, unit);
if (key === 'time') return formatPlannedTourDuration(value);
return formatElevation(value, unit);
}
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
const size = formatValue(key, Math.abs(difference), unit);
if (size === formatValue(key, 0, unit)) return `±${size}`;
return `${difference > 0 ? '+' : '-'}${size}`;
}
/** One value of one side: the number, or a word saying that side does not carry it. */
function ValueCell({
text,
@@ -0,0 +1,18 @@
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { CompareKey } from './planComparison';
/** One value of the comparison as it reads in the unit shown. */
export function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
if (key === 'distance') return formatDistance(value, unit);
if (key === 'time') return formatPlannedTourDuration(value);
return formatElevation(value, unit);
}
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
const size = formatValue(key, Math.abs(difference), unit);
if (size === formatValue(key, 0, unit)) return `±${size}`;
return `${difference > 0 ? '+' : '-'}${size}`;
}
@@ -11,6 +11,16 @@ import { CardAction, RailCard, TourNotice } from '../tourParts';
import { trailPaintKey } from './bikePaint';
import {
percent,
sharesBy,
stretchesOf,
trailClassOf,
UNPAVED,
unpavedShareOf,
type RouteWayEdge,
type RouteWays,
type TrailClass,
} from './routeWays';
import {
RouteWaysProgress,
RouteWaysSource,
ShareBar,
@@ -19,16 +29,6 @@ import {
WaySection,
WaysSection,
} from './RouteWaySections';
import {
sharesBy,
stretchesOf,
trailClassOf,
unpavedShareOf,
UNPAVED,
type RouteWayEdge,
type RouteWays,
type TrailClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
@@ -9,6 +9,7 @@ import { ReadStatusNotice } from '../ReadStatusNotice';
import { InfoDot } from '../tourParts';
import { bikePaintCss, surfacePaintKey, type BikePaintKey } from './bikePaint';
import {
percent,
sharesBy,
stretchesOf,
type ClassShare,
@@ -26,8 +27,6 @@ import type { RouteWaysState } from './useRouteWays';
* and kinds of way, and how the reading went.
*/
export const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
@@ -214,3 +214,17 @@ export function wayIdsOf(ways: RouteWays, limit: number): number[] {
}
return ids;
}
/** A share of the route as it reads, in whole percent. */
export const percent = (share: number) => `${Math.round(share * 100)} %`;
type GradedClass = Exclude<SacClass, 'none'>;
/** The steps from easy to hard, so the hardest one along the route is the last one found. */
const SAC_STEPS: readonly GradedClass[] = ['t1', 't2', 't3', 't4', 't5', 't6'];
/** RUN-02: the hardest graded step along the route, or null when no way on it carries a grade. */
export function hardestSac(ways: RouteWays): GradedClass | null {
const found = new Set(ways.edges.filter((edge) => edge.lengthM > 0).map((edge) => edge.sac));
return [...SAC_STEPS].reverse().find((step) => found.has(step)) ?? null;
}
@@ -30,7 +30,8 @@ import {
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourLinePreview, tourPreviewFacts } from '../library/tourPreviewParts';
import { tourPreviewFacts } from '../library/tourPreviewFacts';
import { TourLinePreview } from '../library/tourPreviewParts';
import { RailFacts, TourNotice } from '../tourParts';
import { ChoiceList, ChoiceRow } from './choiceParts';
import type { CollectionTourTakeOver, PointsChoice } from './useCollectionTourTakeOver';
@@ -1,7 +1,10 @@
import type { CollectionTourItem } from '@trek/shared';
import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useAuthStore } from '../../../store/authStore';
import { useCollectionStore } from '../../../store/collectionStore';
import { useSettingsStore } from '../../../store/settingsStore';
import { canRemoveFromCollection, collectionRoleOf } from '../../../utils/collectionAccess';
import { WhiteBadge } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { fs } from '../../shared/DialogShell';
@@ -195,3 +198,18 @@ export function CollectionToursSection({ collectionId, canRemove }: { collection
</section>
);
}
/**
* The tours of the list open on the collections page, with what the user may
* do there read from the list itself; nothing on "All saved" or before a list opens.
*/
export function ActiveCollectionTours() {
const activeId = useCollectionStore((state) => state.activeId);
const collections = useCollectionStore((state) => state.collections);
const members = useCollectionStore((state) => state.members);
const userId = useAuthStore((state) => state.user?.id);
if (typeof activeId !== 'number') return null;
const isOwner = collections.find((collection) => collection.id === activeId)?.is_owner ?? false;
const canRemove = canRemoveFromCollection(collectionRoleOf(isOwner, members, userId));
return <CollectionToursSection collectionId={activeId} canRemove={canRemove} />;
}
@@ -9,15 +9,16 @@ import { collectionToursRepo } from '../../../repo/collectionToursRepo';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { ALL_SAVED, useCollectionStore } from '../../../store/collectionStore';
import { useCollectionTourStore } from '../../../store/collectionTourStore';
import { CollectionTourTakeOverDialog, KeptPointsStrip } from './CollectionTourTakeOverDialog';
import { CollectionToursSection } from './CollectionToursSection';
import { ActiveCollectionTours, CollectionToursSection } from './CollectionToursSection';
import { SaveTourToCollectionDialog } from './SaveTourToCollectionDialog';
import { TourSaveToCollectionBarButton, TourSaveToCollectionButton } from './TourSaveToCollectionButton';
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
import { useSaveTourToCollection } from './useSaveTourToCollection';
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-015
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-016
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
@@ -407,4 +408,29 @@ describe('CollectionToursSection', () => {
render(<CollectionToursSection collectionId={3} canRemove />);
expect(screen.queryByTestId('collection-tours')).toBeNull();
});
it('FE-COLLECTION-TOURS-016: the open list shows its tours, removable by its owner or an admin only, and "All saved" none', async () => {
const before = useCollectionStore.getState();
const list = vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [kept()] });
try {
useCollectionStore.setState({ activeId: 3, collections: [{ id: 3, is_owner: false }] as never, members: [] });
useAuthStore.setState({ user: { id: 7 } as never });
const viewer = render(<ActiveCollectionTours />);
const card = await screen.findByTestId('kept-tour-4');
expect(list).toHaveBeenCalled();
expect(within(card).queryByTestId('kept-tour-remove-4')).toBeNull();
viewer.unmount();
useCollectionStore.setState({ members: [{ user_id: 7, is_owner: false, role: 'admin' }] as never });
const admin = render(<ActiveCollectionTours />);
expect(await screen.findByTestId('kept-tour-remove-4')).toBeInTheDocument();
admin.unmount();
useCollectionStore.setState({ activeId: ALL_SAVED });
render(<ActiveCollectionTours />);
expect(screen.queryByTestId('collection-tours')).toBeNull();
} finally {
useCollectionStore.setState(before, true);
}
});
});
@@ -131,7 +131,7 @@ export function useCollectionTourTakeOver(collectionId: number) {
openTrip: () => {
if (!done) return;
setTour(null);
navigate(`/trips/${done.tripId}`);
void navigate(`/trips/${done.tripId}`);
},
};
}
@@ -126,7 +126,7 @@ export function useSaveTourToCollection(target: TourSaveTarget) {
retry: () => setOpening((value) => value + 1),
openCollections: () => {
setOpen(false);
navigate('/collections');
void navigate('/collections');
},
};
}
@@ -20,16 +20,12 @@ import { FigureBadge } from '../paddle/FigureBadge';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { AccessLabel, AccessPills, ConnectionField, DirectionField, SectionChips } from './HorseRouteDialogParts';
import { HorseRouteMap } from './HorseRouteMap';
import { horseRouteTitle } from './horseSection';
import { Horseshoe } from './HorseshoeIcon';
import { useHorseSectionPick } from './useHorseSectionPick';
type DialogPlanner = Pick<TourPlannerController, 'waypoints' | 'editWaypoints' | 'isSaving' | 'canEdit' | 'route'>;
/** A route's name as the rider reads it: its own, else its reference, else said to have none. */
export function horseRouteTitle(route: HorseRoute, t: (key: string) => string): string {
return route.name ?? route.ref ?? t('tours.horse.routes.unnamed');
}
/** What the route is at a glance: its reference, its sections, whether it goes on, and a way to OpenStreetMap. */
function RoutePills({ route }: { route: HorseRoute }) {
const { t } = useTranslation();
@@ -8,7 +8,8 @@ import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, InfoDot, RailCard, TourNotice } from '../tourParts';
import { HorseRouteDialog, horseRouteTitle } from './HorseRouteDialog';
import { HorseRouteDialog } from './HorseRouteDialog';
import { horseRouteTitle } from './horseSection';
import { Horseshoe } from './HorseshoeIcon';
import { useHorseRoutes, type HorseRoutes } from './useHorseRoutes';
@@ -79,3 +79,8 @@ export function withSectionAppended(
}
return [...points, { id: newId(), lat: startLat, lng: startLng, role: 'via', fixedLeg: leg }, end];
}
/** A route's name as the rider reads it: its own, else its reference, else said to have none. */
export function horseRouteTitle(route: HorseRoute, t: (key: string) => string): string {
return route.name ?? route.ref ?? t('tours.horse.routes.unnamed');
}
@@ -15,7 +15,8 @@ import {
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { RailFacts, TourNotice } from '../tourParts';
import { TourLinePreview, TourMeasurePills, tourPreviewFacts } from './tourPreviewParts';
import { tourPreviewFacts } from './tourPreviewFacts';
import { TourLinePreview, TourMeasurePills } from './tourPreviewParts';
import { type TourLibrary } from './useTourLibrary';
const ALL = 'all';
@@ -0,0 +1,51 @@
import type { TourLibraryItem } from '@trek/shared';
import { formatDistance, formatElevation } from '../../../utils/units';
import type { TourMetricField } from '../tourParts';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
export type Translate = (key: string, params?: Record<string, unknown>) => string;
export type Unit = Parameters<typeof formatDistance>[1];
/** What a tour picked from elsewhere (another trip, a collection) is measured by, as its list carries it. */
export type MeasuredTour = Pick<
TourLibraryItem,
| 'tour_type'
| 'distance'
| 'elevation_gain'
| 'elevation_loss'
| 'duration'
| 'planned_duration_minutes'
| 'break_additional_minutes'
| 'station_count'
| 'station_stay_minutes'
| 'station_stays_unknown'
>;
/**
* The facts of a chosen tour as its preview shows them, each only where the
* tour has it: where it comes from first, then its type, distance, planned
* time, heights and how many control points it brings.
*/
export function tourPreviewFacts(
tour: MeasuredTour,
waypoints: number,
unit: Unit,
t: Translate,
from: TourMetricField
): TourMetricField[] {
const total = plannedTotalText(tourPlannedTimes(tour), t);
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
from,
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
@@ -1,4 +1,3 @@
import type { TourLibraryItem } from '@trek/shared';
import { ArrowUp, Clock, Loader2, Ruler, Spline } from 'lucide-react';
import { useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
@@ -9,26 +8,8 @@ import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { fs } from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import type { TourMetricField } from '../tourParts';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
/** What a tour picked from elsewhere (another trip, a collection) is measured by, as its list carries it. */
type MeasuredTour = Pick<
TourLibraryItem,
| 'tour_type'
| 'distance'
| 'elevation_gain'
| 'elevation_loss'
| 'duration'
| 'planned_duration_minutes'
| 'break_additional_minutes'
| 'station_count'
| 'station_stay_minutes'
| 'station_stays_unknown'
>;
import type { MeasuredTour, Translate, Unit } from './tourPreviewFacts';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: MeasuredTour, unit: Unit, t: Translate) {
@@ -78,35 +59,6 @@ export function TourMeasurePills({ tour, testPrefix }: { tour: MeasuredTour; tes
);
}
/**
* The facts of a chosen tour as its preview shows them, each only where the
* tour has it: where it comes from first, then its type, distance, planned
* time, heights and how many control points it brings.
*/
export function tourPreviewFacts(
tour: MeasuredTour,
waypoints: number,
unit: Unit,
t: Translate,
from: TourMetricField
): TourMetricField[] {
const total = plannedTotalText(tourPlannedTimes(tour), t);
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
from,
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** Where a preview's answer is: on its way, there, or not to be had. */
type PreviewStatus = 'loading' | 'ready' | 'error' | 'offline' | null;
@@ -7,7 +7,8 @@ import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { CardAction, TourNotice } from '../tourParts';
import { CategoryDot, DimmedLink, linkName } from './packingLinkParts';
import { linkName } from './packingLinkName';
import { CategoryDot, DimmedLink } from './packingLinkParts';
import { TourPackingLinkDialog } from './TourPackingLinkDialog';
import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink';
@@ -0,0 +1,6 @@
import type { TourPackingLink } from '@trek/shared';
/** The name of what the tour points at: a category by its own name, otherwise the whole list. */
export function linkName(link: TourPackingLink, t: (key: string) => string): string {
return link.category ?? t('tours.packing.whole');
}
@@ -3,11 +3,7 @@ import { Package, PowerOff } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { tintOf, WhiteBadge } from '../../Planner/planParts';
import { TourNotice } from '../tourParts';
/** The name of what the tour points at: a category by its own name, otherwise the whole list. */
export function linkName(link: TourPackingLink, t: (key: string) => string): string {
return link.category ?? t('tours.packing.whole');
}
import { linkName } from './packingLinkName';
/** A link while Packing is off: dimmed, not hidden, since the tour still points at the list it cannot show. */
export function DimmedLink({ link }: { link: TourPackingLink }) {
@@ -1,7 +1,7 @@
// FE-TOUR-LEG-VARIANTS-CARD-001 to FE-TOUR-LEG-VARIANTS-CARD-007: the card that compares the ways
// of a hike's section over the planner map (RTE-02).
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { idleLegVariants } from '../../../../tests/helpers/tourPlannerStub';
@@ -790,12 +790,14 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
toast.success(t('tours.planner.saved'))
}, [savedTour, t, toast])
// MDT-02: the stage rail is set up after the opening it asks for, so a failed open reaches its cancel through a ref.
const cancelStage = useRef<() => void>(() => undefined)
const openTour = async (tour: TourListItem) => {
const opened = await planner.openTour(tour)
if (opened) return
toast.error(t('tours.planner.openFailed'))
// MDT-02: a stage asked for with this tour is not shown on a later open of it.
stageRail.cancel()
cancelStage.current()
}
const selectTour = (tour: TourListItem) => {
@@ -814,6 +816,9 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
}
// MDT-02: a multi-day tour unfolds its stages here, and a stage opens in the planner.
const stageRail = useTourStageRail(planner, requestOpenTour)
useEffect(() => {
cancelStage.current = stageRail.cancel
}, [stageRail.cancel])
const tourTimeFields = plannerTimeFields(planner, t)
@@ -6,7 +6,7 @@ import ToggleSwitch from '../../Settings/ToggleSwitch';
import { Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { InfoDot } from '../tourParts';
import { WINTER_ACTIVITY_ICON } from '../winter/winterMarks';
import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon';
import type { TourPlannerController } from './useTourPlanner';
/**
@@ -34,6 +34,8 @@ export function openedTourFields(tour: TourListItem) {
tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType,
bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile,
winterActivity: tour.winter_activity ?? null,
/** HIK-01: the label the winter switch brings back, the tour's own or winter hiking. */
winterActivityChoice: tour.winter_activity ?? ('winter_hike' satisfies WinterActivity),
runProfile: (tour.run_profile ?? 'running') as RunProfile,
runPaceSecondsPerKm: tour.run_pace_seconds_per_km ?? null,
};
@@ -137,7 +137,12 @@ describe('tourPlannerFacts', () => {
runPaceSecondsPerKm: 330,
});
const hikeOpened = openedTourFields({ name: 'Ridge', tour_type: 'hike' } as Parameters<typeof openedTourFields>[0]);
expect(hikeOpened).toMatchObject({ runProfile: 'running', runPaceSecondsPerKm: null });
expect(hikeOpened).toMatchObject({
runProfile: 'running',
runPaceSecondsPerKm: null,
winterActivity: null,
winterActivityChoice: 'winter_hike',
});
});
it('holds the fields to their bounds, and a total that runs past a day without an override', () => {
@@ -51,10 +51,14 @@ describe('useAppliedWayHint', () => {
act(() => view.result.current.note(BEFORE));
view.rerender({ points: AFTER });
act(() => view.result.current.view.hold(true));
act(() => vi.advanceTimersByTime(APPLIED_HINT_MS * 2));
act(() => {
vi.advanceTimersByTime(APPLIED_HINT_MS * 2);
});
expect(view.result.current.view.shown).toBe(true);
act(() => view.result.current.view.hold(false));
act(() => vi.advanceTimersByTime(APPLIED_HINT_MS));
act(() => {
vi.advanceTimersByTime(APPLIED_HINT_MS);
});
expect(view.result.current.view.shown).toBe(false);
});
@@ -160,20 +160,22 @@ export function useLegVariants(options: LegVariantsOptions) {
return currentVariantOf(waypoints, index, route, enrichedGeometry);
}, [enrichedGeometry, request, route, waypoints]);
// The way taken as it was when the comparison was asked for, read here so the memo below depends on it by name.
const askedCurrent = request?.current ?? null;
const comparison = useMemo((): LegComparison | null => {
if (!request) return null;
if (!request || !askedCurrent) return null;
const found = sameRequest(answer, request) ? answer : null;
const current = liveCurrent ?? request.current;
const current = liveCurrent ?? askedCurrent;
const checked = sameRequest(proof, request) ? proof.state : null;
return {
leg: request.leg,
phase: found?.phase ?? 'loading',
variants: (found?.variants ?? [request.current]).map((variant) => (variant.current ? current : variant)),
variants: (found?.variants ?? [askedCurrent]).map((variant) => (variant.current ? current : variant)),
selected: sameRequest(choice, request) ? choice.key : null,
proving: checked === 'proving',
notice: checked === 'proving' ? null : checked,
};
}, [answer, choice, liveCurrent, proof, request]);
}, [answer, askedCurrent, choice, liveCurrent, proof, request]);
const apply = useCallback(async () => {
const selected = comparison?.selected;
@@ -778,7 +778,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRunProfileState(opened.runProfile)
loadRunPace(opened.runPaceSecondsPerKm)
setWinterActivityState(opened.winterActivity)
setWinterActivityChoice(opened.winterActivity ?? 'winter_hike')
setWinterActivityChoice(opened.winterActivityChoice)
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
@@ -180,6 +180,22 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
setError(null);
}
/**
* Plans a run a moment from now, on the water or on land; a failure that is
* still the current run says the route could not be planned. Returns the way
* to call it off before it starts.
*/
function scheduleRoute(run: RouteRun): () => void {
const timer = window.setTimeout(() => {
(run.inputs.tourType === 'kayak' ? routePaddle(run) : routeOnLand(run)).catch(() => {
if (run.stale()) return;
run.inputs.setStatus('routing-failed');
run.inputs.setError('routing');
});
}, ROUTE_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}
/**
* The route of the planner's draft, planned again a moment after its points
* or its choices change: a hike on foot within its limit, a bike tour and a
@@ -287,16 +303,9 @@ export function useTourRoute(inputs: TourRouteInputs) {
setUnreachable,
};
const timer = window.setTimeout(() => {
(tourType === 'kayak' ? routePaddle(run) : routeOnLand(run)).catch(() => {
if (stale()) return;
setStatus('routing-failed');
setError('routing');
});
}, ROUTE_DEBOUNCE_MS);
const cancelTimer = scheduleRoute(run);
return () => {
window.clearTimeout(timer);
cancelTimer();
controller.abort();
};
}, [
@@ -9,7 +9,8 @@ import ConfirmDialog from '../../shared/ConfirmDialog';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../tourParts';
import { hintWhen, SourceHintPills, SourceHintTile } from '../wanderer/sourceHintParts';
import { hintWhen } from '../wanderer/hintWhen';
import { SourceHintPills, SourceHintTile } from '../wanderer/sourceHintParts';
import WandererMark from '../wanderer/WandererMark';
import { type SourceCheck, useSourceCheck } from './useSourceCheck';
import { useSourceHint } from './useSourceHint';
@@ -248,6 +249,22 @@ interface TourProvenanceSectionProps {
hint?: TourSourceHint | null;
}
/** The days the section names: when the tour came in (or was made here), and when its wanderer trail last changed. */
function provenanceDays({ source, createdAt }: TourProvenance, locale: string) {
const wanderer = source.kind === 'wanderer' ? source : null;
return {
importedAt: dayOf(wanderer?.importedAt ?? createdAt, locale),
externalUpdated: wanderer ? dayOf(wanderer.externalUpdated, locale) : null,
};
}
/** The mark of where a tour came from: a GPX file, a wanderer trail, or planned here. */
function SourceIcon({ kind }: { kind: TourProvenance['source']['kind'] }) {
if (kind === 'gpx') return <FileDown size={13} strokeWidth={2} />;
if (kind === 'wanderer') return <WandererMark size={13} />;
return <PenLine size={13} strokeWidth={2} />;
}
/**
* P2C-02 in the tour's detail: where it came from (planned here, a GPX file,
* a wanderer trail with its link), when, and how it stands here against what
@@ -258,19 +275,10 @@ export default function TourProvenanceSection({ provenance, check, hint }: TourP
const { t, locale } = useTranslation();
const { source } = provenance;
const imported = source.kind !== 'trek';
const importedAt = dayOf((source.kind === 'wanderer' ? source.importedAt : null) ?? provenance.createdAt, locale);
const externalUpdated = source.kind === 'wanderer' ? dayOf(source.externalUpdated, locale) : null;
const { importedAt, externalUpdated } = provenanceDays(provenance, locale);
const local = <LocalState provenance={provenance} t={t} />;
// The hint carries the check button until the source answered a check, which spends it.
const openHint = check && hint && !check.answered ? hint : null;
const sourceIcon =
source.kind === 'gpx' ? (
<FileDown size={13} strokeWidth={2} />
) : source.kind === 'wanderer' ? (
<WandererMark size={13} />
) : (
<PenLine size={13} strokeWidth={2} />
);
return (
<DialogSection
@@ -279,7 +287,7 @@ export default function TourProvenanceSection({ provenance, check, hint }: TourP
action={check && !openHint && <CheckButton check={check} t={t} />}
>
<div className={`${BOX} divide-y divide-edge-faint`}>
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
<ProvenanceRow icon={<SourceIcon kind={source.kind} />} label={t('tours.provenance.source')}>
<SourceValue provenance={provenance} t={t} />
</ProvenanceRow>
{provenance.takenOverFrom && (
@@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { RouteWays } from '../bike/routeWays';
import { hardestSac } from '../bike/routeWays';
import type { RouteWaysState } from '../bike/useRouteWays';
import { hardestSac, RunWaysCard } from './RunWaysCard';
import { RunWaysCard } from './RunWaysCard';
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
vi.mock('../bike/useRouteWays', () => ({
@@ -3,22 +3,10 @@ import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import { percent } from '../bike/RouteWaySections';
import { unpavedShareOf, type RouteWays, type SacClass } from '../bike/routeWays';
import { hardestSac, percent, unpavedShareOf, type RouteWays } from '../bike/routeWays';
import { HikeWaysCard } from '../hike/HikeWaysCard';
import type { TourPlannerController } from '../planner/useTourPlanner';
type GradedClass = Exclude<SacClass, 'none'>;
/** The steps from easy to hard, so the hardest one along the route is the last one found. */
const SAC_STEPS: readonly GradedClass[] = ['t1', 't2', 't3', 't4', 't5', 't6'];
/** The hardest graded step along the route, or null when no way on it carries a grade. */
export function hardestSac(ways: RouteWays): GradedClass | null {
const found = new Set(ways.edges.filter((edge) => edge.lengthM > 0).map((edge) => edge.sac));
return [...SAC_STEPS].reverse().find((step) => found.has(step)) ?? null;
}
/**
* The card's head: how much of the run is known to be unpaved, and the
* hardest graded path on it, named in the words the hike's trail details use.
@@ -4,7 +4,7 @@ import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { InfoDot, TourNotice } from '../tourParts';
import { skiChoiceMissing, type SkiChoice } from './skiChoice';
import { SKI_DISCIPLINE_ICON } from './skiMarks';
import { SKI_DISCIPLINE_ICON } from './skiDiscipline';
/**
* SKI-02: what a ski track is, chosen on purpose: the discipline, none until
@@ -4,7 +4,7 @@ import { fs } from '../../shared/DialogShell';
import { FigureBadge } from '../paddle/FigureBadge';
import { CardAction, InfoDot, RailCard, RailFacts } from '../tourParts';
import { SkiClassifyDialog } from './SkiClassifyDialog';
import { SKI_DISCIPLINE_ICON, skiDisciplineOf, skiTechniqueOf } from './skiMarks';
import { SKI_DISCIPLINE_ICON, skiDisciplineOf, skiTechniqueOf } from './skiDiscipline';
import type { SkiClassification } from './useSkiClassification';
/**
@@ -0,0 +1,49 @@
import type { SkiDiscipline, SkiTechnique, TourListItem } from '@trek/shared';
import { CableCar, createLucideIcon, Spline, TrendingUp, type LucideIcon } from 'lucide-react';
/**
* SKI-02: a snowboard seen from above, laid across the square with its two
* bindings. Lucide has no board, and the snowflake is the winter hike's mark
* (HIK-01): both sit in the same corner badge, so the board needs a glyph of
* its own to be told apart there.
*/
const BOARD = 'rotate(-45 12 12)';
const Snowboard = createLucideIcon('snowboard', [
['rect', { x: '1.5', y: '8.5', width: '21', height: '7', rx: '3.5', transform: BOARD, key: 'board' }],
['path', { d: 'M8.5 10v4', transform: BOARD, key: 'binding-front' }],
['path', { d: 'M15.5 10v4', transform: BOARD, key: 'binding-back' }],
]);
/**
* SKI-02: the icon each discipline goes by: a lift for alpine skiing, a board
* for snowboarding, a winding trail for cross-country and the climb on skins
* for ski touring. None of them is the winter hike's snowflake, which takes
* the same corner of a tile.
*/
export const SKI_DISCIPLINE_ICON: Record<SkiDiscipline, LucideIcon> = {
alpine: CableCar,
snowboard: Snowboard,
cross_country: Spline,
touring: TrendingUp,
};
export type SkiTour = Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>>;
/** The discipline of a ski track, or null for any other tour and a ski track listed before it had one. */
export function skiDisciplineOf(tour: SkiTour): SkiDiscipline | null {
return tour.tour_type === 'ski' ? (tour.ski_discipline ?? null) : null;
}
/** A cross-country track's technique; null for every other discipline and type. */
export function skiTechniqueOf(tour: SkiTour): SkiTechnique | null {
return skiDisciplineOf(tour) === 'cross_country' ? (tour.ski_technique ?? null) : null;
}
/** The classification in words, the discipline and a cross-country technique, as one tooltip reads it. */
export function skiClassificationText(tour: SkiTour, t: (key: string) => string): string | null {
const discipline = skiDisciplineOf(tour);
if (!discipline) return null;
const technique = skiTechniqueOf(tour);
const name = t(`tours.ski.discipline.${discipline}`);
return technique ? `${name}, ${t('tours.ski.technique')}: ${t(`tours.ski.technique.${technique}`)}` : name;
}
@@ -2,15 +2,9 @@ import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { TourDayRowAvatar } from '../TourDayRowFacts';
import { TourGradeTile } from '../tourParts';
import { WINTER_ACTIVITY_ICON } from '../winter/winterMarks';
import {
SKI_DISCIPLINE_ICON,
SkiCornerMark,
SkiHeadPill,
skiClassificationText,
skiDisciplineOf,
skiTechniqueOf,
} from './skiMarks';
import { WINTER_ACTIVITY_ICON } from '../winter/winterActivityIcon';
import { SKI_DISCIPLINE_ICON, skiClassificationText, skiDisciplineOf, skiTechniqueOf } from './skiDiscipline';
import { SkiCornerMark, SkiHeadPill } from './skiMarks';
// FE-TOUR-SKI-MARK-001 to FE-TOUR-SKI-MARK-006
+7 -49
View File
@@ -1,56 +1,14 @@
import type { SkiDiscipline, SkiTechnique, TourListItem } from '@trek/shared';
import { CableCar, createLucideIcon, Spline, TrendingUp, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourCornerMark } from '../TourCornerMark';
/**
* SKI-02: a snowboard seen from above, laid across the square with its two
* bindings. Lucide has no board, and the snowflake is the winter hike's mark
* (HIK-01): both sit in the same corner badge, so the board needs a glyph of
* its own to be told apart there.
*/
const BOARD = 'rotate(-45 12 12)';
const Snowboard = createLucideIcon('snowboard', [
['rect', { x: '1.5', y: '8.5', width: '21', height: '7', rx: '3.5', transform: BOARD, key: 'board' }],
['path', { d: 'M8.5 10v4', transform: BOARD, key: 'binding-front' }],
['path', { d: 'M15.5 10v4', transform: BOARD, key: 'binding-back' }],
]);
/**
* SKI-02: the icon each discipline goes by: a lift for alpine skiing, a board
* for snowboarding, a winding trail for cross-country and the climb on skins
* for ski touring. None of them is the winter hike's snowflake, which takes
* the same corner of a tile.
*/
export const SKI_DISCIPLINE_ICON: Record<SkiDiscipline, LucideIcon> = {
alpine: CableCar,
snowboard: Snowboard,
cross_country: Spline,
touring: TrendingUp,
};
type SkiTour = Partial<Pick<TourListItem, 'tour_type' | 'ski_discipline' | 'ski_technique'>>;
/** The discipline of a ski track, or null for any other tour and a ski track listed before it had one. */
export function skiDisciplineOf(tour: SkiTour): SkiDiscipline | null {
return tour.tour_type === 'ski' ? (tour.ski_discipline ?? null) : null;
}
/** A cross-country track's technique; null for every other discipline and type. */
export function skiTechniqueOf(tour: SkiTour): SkiTechnique | null {
return skiDisciplineOf(tour) === 'cross_country' ? (tour.ski_technique ?? null) : null;
}
/** The classification in words, the discipline and a cross-country technique, as one tooltip reads it. */
export function skiClassificationText(tour: SkiTour, t: (key: string) => string): string | null {
const discipline = skiDisciplineOf(tour);
if (!discipline) return null;
const technique = skiTechniqueOf(tour);
const name = t(`tours.ski.discipline.${discipline}`);
return technique ? `${name}, ${t('tours.ski.technique')}: ${t(`tours.ski.technique.${technique}`)}` : name;
}
import {
SKI_DISCIPLINE_ICON,
skiClassificationText,
skiDisciplineOf,
skiTechniqueOf,
type SkiTour,
} from './skiDiscipline';
/**
* SKI-02: a ski track's discipline on the corner of its round tile, a frosted
@@ -1,19 +1,14 @@
import type { TourListItem } from '@trek/shared';
import type { ReactNode } from 'react';
import { ArrowDown, ArrowRight, ArrowUp, Clock, Moon, Ruler, Sunrise, type LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import type { useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { TourStage } from './tourStages';
import type { TourStagesController } from './useTourStages';
type Translate = ReturnType<typeof useTranslation>['t'];
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
/**
* MDT-01: the moon at the corner of a stop's number where a stage ends, the
@@ -51,7 +46,7 @@ export function StagePill({ number }: { number: number }) {
}
/** A stage's number as the list numbers a stop, with the moon at its corner where a night follows. */
export function StageNumber({ number, night }: { number: number; night: boolean }) {
function StageNumber({ number, night }: { number: number; night: boolean }) {
return (
<span
className="relative grid h-7 w-7 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
@@ -63,21 +58,6 @@ export function StageNumber({ number, night }: { number: number; night: boolean
);
}
/**
* The name a stage's stop goes by: its own, else the place found there, else
* its number as the list numbers stops (a shaping point takes none).
*/
export function stopNameOf(
points: ReadonlyArray<{ id: string; name?: string | null; shaping?: boolean }>,
placeNames: Readonly<Record<string, string>>,
id: string,
t: Translate
): string {
const index = points.findIndex((point) => point.id === id);
const stop = points.slice(0, index + 1).filter((point) => !point.shaping).length;
return points[index]?.name || placeNames[id] || t('tours.planner.waypointLabel', { n: stop });
}
/**
* What a stage measures, as the plan's white badges: its distance, climb and
* descent where the route has heights, and its moving time. A time over a day
@@ -157,7 +137,12 @@ export function StageSummary({
style={fs(12.5, 'body')}
>
<span className="min-w-0 truncate">{from}</span>
<ArrowRight size={12} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint rtl:rotate-180" />
<ArrowRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-content-faint rtl:rotate-180"
/>
<span className="min-w-0 truncate">{to}</span>
</span>
<StageMetricBadges stage={stage} unmeasured={unmeasured} />
@@ -185,20 +170,6 @@ export function StageDivider({ stage }: { stage: TourStage }) {
);
}
/** The stage tool of a stop's menu: end a stage here, or let the stage go on; none where no stage can end. */
export function stageMenuItems(stages: TourStagesController, point: { id: string; stageEnd?: boolean }, t: Translate) {
if (!stages.canEnd(point.id)) return [] as MenuItems;
const items: MenuItems = [
{ divider: true },
{
label: t(point.stageEnd ? 'tours.stages.removeEnd' : 'tours.stages.endHere'),
icon: Moon,
onClick: () => stages.toggleStageEnd(point.id),
},
];
return items;
}
/**
* MDT-02: how many stages a multi-day tour has, as a small moon with the
* number beside it, in the corner of what it sits on. Its words are said by
@@ -1,14 +1,9 @@
import type { TourListItem } from '@trek/shared';
import { useTripStore } from '../../../store/tripStore';
import { StageMetricBadges, StagePill } from './StageParts';
import { stageNumberOf } from './stageNames';
import { useSavedTourStages } from './useSavedTourStages';
/** The number of the stage a day holds, from the stops the tour list names; null for a stage the tour no longer has. */
export function stageNumberOf(tour: Pick<TourListItem, 'stage_stops'>, stageKey: string): number | null {
const index = (tour.stage_stops ?? []).slice(0, -1).findIndex((stop) => stop.key === stageKey);
return index >= 0 ? index + 1 : null;
}
/**
* MDT-02: a day that holds one stage of a multi-day tour says which, with the
* stage's own distance and time rather than the whole tour's, as far as the
@@ -12,7 +12,8 @@ import { movingTimeLabel } from '../tourFacts';
import { TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { StageDayPicker } from './StageDayPicker';
import { StageSummary, stopNameOf } from './StageParts';
import { stopNameOf } from './stageNames';
import { StageSummary } from './StageParts';
import type { StageSum, StageTotals } from './tourStages';
import { useSavedTourStages } from './useSavedTourStages';
import { useTourStageDays, type TourStageDays } from './useTourStageDays';
@@ -30,15 +31,31 @@ interface TourStageOverviewProps {
}
/** A sum as a badge: the total of the stages that tell it, marked as at least that where some do not. */
function TotalBadge({ icon: Icon, label, sum, format, t }: { icon: LucideIcon; label: string; sum: StageSum; format: (value: number) => string; t: Translate }) {
function TotalBadge({
icon: Icon,
label,
sum,
format,
t,
}: {
icon: LucideIcon;
label: string;
sum: StageSum;
format: (value: number) => string;
t: Translate;
}) {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
const said = partial ? `${label}: ${value} (${t('tours.stages.totalPartial', { known: sum.known, of: sum.of })})` : `${label}: ${value}`;
const said = partial
? `${label}: ${value} (${t('tours.stages.totalPartial', { known: sum.known, of: sum.of })})`
: `${label}: ${value}`;
return (
<Tooltip label={said}>
<span className="inline-flex" aria-label={said} data-stage-total-partial={partial || undefined}>
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{value}</WhiteBadge>
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{value}
</WhiteBadge>
</span>
</Tooltip>
);
@@ -56,12 +73,24 @@ export function StageTotalsRow({ tour, totals }: { tour: TourListItem; totals: S
// A route not planned yet adds up to nothing: no row rather than an empty one.
if (Object.values(totals).every((sum) => sum.value === null)) return null;
return (
<div data-testid="tour-stage-totals" className="mt-1 flex min-w-0 flex-wrap items-center gap-1 border-t border-edge-faint px-1.5 pb-0.5 pt-2">
<span className="me-auto inline-flex items-center gap-1 font-semibold text-content-secondary" style={fs(11.5, 'body')}>
<div
data-testid="tour-stage-totals"
className="mt-1 flex min-w-0 flex-wrap items-center gap-1 border-t border-edge-faint px-1.5 pb-0.5 pt-2"
>
<span
className="me-auto inline-flex items-center gap-1 font-semibold text-content-secondary"
style={fs(11.5, 'body')}
>
<Sigma size={12} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
{t('tours.stages.wholeTour')}
</span>
<TotalBadge icon={Ruler} label={t('tours.detail.distance')} sum={totals.distanceKm} format={(value) => formatDistance(value, unit)} t={t} />
<TotalBadge
icon={Ruler}
label={t('tours.detail.distance')}
sum={totals.distanceKm}
format={(value) => formatDistance(value, unit)}
t={t}
/>
<TotalBadge icon={ArrowUp} label={t('tours.detail.ascent')} sum={totals.gain} format={height} t={t} />
<TotalBadge icon={ArrowDown} label={t('tours.detail.descent')} sum={totals.loss} format={height} t={t} />
<TotalBadge
@@ -147,7 +176,11 @@ export function TourStageOverview({ id, tripId, tour, days, canAssign, onOpenSta
>
{saved.status === 'error' && <TourNotice>{t('tours.stages.loadFailed')}</TourNotice>}
{saved.status === 'loading' && (
<p className="m-0 flex items-center gap-1.5 px-2 py-2 text-content-faint" style={fs(11.5, 'body')} role="status">
<p
className="m-0 flex items-center gap-1.5 px-2 py-2 text-content-faint"
style={fs(11.5, 'body')}
role="status"
>
<Loader2 size={12} strokeWidth={2.2} aria-hidden="true" className="animate-spin" />
{t('common.loading')}
</p>
@@ -156,7 +189,12 @@ export function TourStageOverview({ id, tripId, tour, days, canAssign, onOpenSta
<>
<ol aria-label={t('tours.stages.title')} className="m-0 flex list-none flex-col gap-px p-0">
{saved.stages.map((stage) => (
<li key={stage.number} data-testid="tour-stage-item" data-stage={stage.number} className="flex min-w-0 items-start gap-2.5 rounded-[10px] px-1.5 py-1.5">
<li
key={stage.number}
data-testid="tour-stage-item"
data-stage={stage.number}
className="flex min-w-0 items-start gap-2.5 rounded-[10px] px-1.5 py-1.5"
>
<StageSummary
stage={stage}
night={stage.number < saved.stages.length}
@@ -1,26 +1,13 @@
import { useTranslation } from '../../../i18n';
import { focusRouteProfileAtDistance } from '../../../utils/routeGeometry';
import { Tooltip } from '../../shared/Tooltip';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { InfoDot, TourSection } from '../tourParts';
import { StageSummary, stopNameOf } from './StageParts';
import { showStage } from './stageActions';
import { stopNameOf } from './stageNames';
import { StageSummary } from './StageParts';
import type { TourStage } from './tourStages';
/**
* Shows a stage of the tour open in the planner: its last stop selected, the
* profile at the spot it ends once the route has measured it (MDT-02 opens a
* stage from the list this way too).
*/
export function showStage(
planner: Pick<TourPlannerController, 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'>,
stage: TourStage
) {
planner.setSelectedWaypointId(stage.toId);
const samples = planner.routeAnalysis?.distanceIndexedProfileSamples ?? [];
planner.setRouteProfileFocus(stage.toM === null ? null : focusRouteProfileAtDistance(samples, stage.toM));
}
type StagesPlanner = Pick<
TourPlannerController,
'stages' | 'waypoints' | 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'
@@ -44,7 +31,7 @@ export function TourStagesSection({ planner }: { planner: StagesPlanner }) {
label={t('tours.stages.title')}
action={<InfoDot label={t('tours.stages.title')} description={t('tours.stages.timeInfo')} />}
>
<ol data-testid="tour-stages" className="-mx-1.5 m-0 flex list-none flex-col gap-px p-0">
<ol data-testid="tour-stages" className="m-0 -mx-1.5 flex list-none flex-col gap-px p-0">
{stages.map((stage) => (
<li key={stage.number}>
<Tooltip label={t('tours.stages.show')}>
@@ -0,0 +1,38 @@
import { Moon } from 'lucide-react';
import type { useTranslation } from '../../../i18n';
import { focusRouteProfileAtDistance } from '../../../utils/routeGeometry';
import type { useContextMenu } from '../../shared/ContextMenu';
import type { TourPlannerController } from '../planner/useTourPlanner';
import type { TourStage } from './tourStages';
import type { TourStagesController } from './useTourStages';
type Translate = ReturnType<typeof useTranslation>['t'];
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
/**
* Shows a stage of the tour open in the planner: its last stop selected, the
* profile at the spot it ends once the route has measured it (MDT-02 opens a
* stage from the list this way too).
*/
export function showStage(
planner: Pick<TourPlannerController, 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'>,
stage: TourStage
) {
planner.setSelectedWaypointId(stage.toId);
const samples = planner.routeAnalysis?.distanceIndexedProfileSamples ?? [];
planner.setRouteProfileFocus(stage.toM === null ? null : focusRouteProfileAtDistance(samples, stage.toM));
}
/** The stage tool of a stop's menu: end a stage here, or let the stage go on; none where no stage can end. */
export function stageMenuItems(stages: TourStagesController, point: { id: string; stageEnd?: boolean }, t: Translate) {
if (!stages.canEnd(point.id)) return [] as MenuItems;
const items: MenuItems = [
{ divider: true },
{
label: t(point.stageEnd ? 'tours.stages.removeEnd' : 'tours.stages.endHere'),
icon: Moon,
onClick: () => stages.toggleStageEnd(point.id),
},
];
return items;
}
@@ -0,0 +1,25 @@
import type { TourListItem } from '@trek/shared';
import type { useTranslation } from '../../../i18n';
type Translate = ReturnType<typeof useTranslation>['t'];
/**
* The name a stage's stop goes by: its own, else the place found there, else
* its number as the list numbers stops (a shaping point takes none).
*/
export function stopNameOf(
points: ReadonlyArray<{ id: string; name?: string | null; shaping?: boolean }>,
placeNames: Readonly<Record<string, string>>,
id: string,
t: Translate
): string {
const index = points.findIndex((point) => point.id === id);
const stop = points.slice(0, index + 1).filter((point) => !point.shaping).length;
return points[index]?.name || placeNames[id] || t('tours.planner.waypointLabel', { n: stop });
}
/** The number of the stage a day holds, from the stops the tour list names; null for a stage the tour no longer has. */
export function stageNumberOf(tour: Pick<TourListItem, 'stage_stops'>, stageKey: string): number | null {
const index = (tour.stage_stops ?? []).slice(0, -1).findIndex((stop) => stop.key === stageKey);
return index >= 0 ? index + 1 : null;
}
@@ -17,8 +17,9 @@ import TourListRow from '../TourListRow';
import { tourPlannedTimes } from '../tourPresentation';
import { savedStagePoints, savedTourStagesOf } from './savedTourStages';
import { StageDayPicker } from './StageDayPicker';
import { stageNumberOf } from './stageNames';
import { TourStageDayNotice } from './TourStageDayNotice';
import { stageNumberOf, TourStageDayPills } from './TourStageDayPills';
import { TourStageDayPills } from './TourStageDayPills';
import { StageTotalsRow, TourStageOverview } from './TourStageOverview';
import type { TourStageDays } from './useTourStageDays';
@@ -6,11 +6,12 @@ import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { StageDivider, stageMenuItems, TourStagesBadge } from './StageParts';
import { stageMenuItems } from './stageActions';
import { StageDivider, TourStagesBadge } from './StageParts';
import { TourStageLossNotice } from './TourStageLossNotice';
import { TourStageModeField } from './TourStageModeField';
import { TourStagesSection } from './TourStagesSection';
import type { TourStage } from './tourStages';
import { TourStagesSection } from './TourStagesSection';
import type { TourStagesController } from './useTourStages';
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({ b: 'Upper hut' }) }));
@@ -1,7 +1,7 @@
import type { TourListItem } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { showStage } from './TourStagesSection';
import { showStage } from './stageActions';
type RailPlanner = Pick<
TourPlannerController,
@@ -32,6 +32,8 @@ const openPlaceId = (planner: Pick<TourPlannerController, 'mode'>) =>
export function useTourStageRail(planner: RailPlanner, openTour: (tour: TourListItem) => void) {
const [unfolded, setUnfolded] = useState<ReadonlySet<number>>(() => new Set());
const [pending, setPending] = useState<PendingStage | null>(null);
// The stage asked for that the effect below has settled (shown, or let go as stale); it is not shown twice.
const settled = useRef<PendingStage | null>(null);
const isUnfolded = useCallback((placeId: number) => unfolded.has(placeId), [unfolded]);
const toggle = useCallback((placeId: number) => {
@@ -55,22 +57,20 @@ export function useTourStageRail(planner: RailPlanner, openTour: (tour: TourList
const open = openPlaceId(planner);
const { waypoints } = planner;
const { stages } = planner.stages;
// Another tour opened instead: the stage asked for is no longer wanted. Let go
// while rendering, as React adjusts state to a changed input, not in an effect.
if (pending && open !== pending.placeId && open !== pending.from) setPending(null);
useEffect(() => {
if (!pending) return;
if (open !== pending.placeId) {
// Another tour opened instead: the stage asked for is no longer wanted.
if (open !== pending.from) setPending(null);
return;
}
if (!pending || settled.current === pending || open !== pending.placeId) return;
const start = waypoints.find((point) => point.key === pending.key);
const stage = start && stages.find((item) => item.fromId === start.id);
if (!stage) {
// The tour's points are in and no stage starts at that stop: the list it came from was stale.
if (waypoints.length > 0) setPending(null);
if (waypoints.length > 0) settled.current = pending;
return;
}
showStage(planner, stage);
setPending(null);
settled.current = pending;
}, [open, pending, planner, stages, waypoints]);
return { isUnfolded, toggle, openStage, cancel };
@@ -12,7 +12,8 @@ import {
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts';
import { hintWhen } from './hintWhen';
import { SourceHintPills, SourceHintTile } from './sourceHintParts';
import WandererMark from './WandererMark';
/** One hint: the tour and its trip, what the watch found, when, and the way to the tour. */
@@ -0,0 +1,7 @@
/** A stored instant as the reader's day and time; the hints say when the watch last looked. */
export function hintWhen(iso: string | null, locale: string): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts';
import { hintWhen } from './hintWhen';
import { SourceHintPills, SourceHintTile } from './sourceHintParts';
// Rendered without the translation provider: every text reads as its key.
describe('sourceHintParts (WAN-02)', () => {
@@ -53,11 +53,3 @@ export function SourceHintTile({ state }: { state: TourSourceHintState }) {
</span>
);
}
/** A stored instant as the reader's day and time; the hints say when the watch last looked. */
export function hintWhen(iso: string | null, locale: string): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
@@ -0,0 +1,8 @@
import type { WinterActivity } from '@trek/shared';
import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
/** HIK-01: the icon each winter label goes by, a snowflake for winter hiking and footprints in the snow for snowshoes. */
export const WINTER_ACTIVITY_ICON: Record<WinterActivity, LucideIcon> = {
winter_hike: Snowflake,
snowshoe: Footprints,
};
@@ -1,15 +1,9 @@
import type { WinterActivity } from '@trek/shared';
import { Footprints, Snowflake, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourCornerMark } from '../TourCornerMark';
/** HIK-01: the icon each winter label goes by, a snowflake for winter hiking and footprints in the snow for snowshoes. */
export const WINTER_ACTIVITY_ICON: Record<WinterActivity, LucideIcon> = {
winter_hike: Snowflake,
snowshoe: Footprints,
};
import { WINTER_ACTIVITY_ICON } from './winterActivityIcon';
/**
* HIK-01: a hike's winter label on its round tile, a frosted badge at the
+2 -4
View File
@@ -18,7 +18,7 @@ import ImportCollectionModal from '../components/Collections/ImportCollectionMod
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import LabelManager from '../components/Collections/LabelManager'
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
import { CollectionToursSection } from '../components/Tours/collection/CollectionToursSection'
import { ActiveCollectionTours } from '../components/Tours/collection/CollectionToursSection'
import { useCollections } from './collections/useCollections'
import EmptyState from '../components/shared/EmptyState'
import '../styles/dashboard.css'
@@ -317,9 +317,7 @@ function CollectionsPageDesktop(): React.ReactElement {
)}
{/* The whole tours the list keeps (LIB-03), above its places. */}
{isRealList && typeof c.activeId === 'number' && (
<CollectionToursSection collectionId={c.activeId} canRemove={c.canDelete} />
)}
{isRealList && <ActiveCollectionTours />}
{body}
</>
)}
@@ -9,7 +9,7 @@ import { useToast } from '../../components/shared/Toast'
import { collectionsApi } from '../../api/collections'
import { downloadCollectionFile, downloadCollectionGpx, type CollectionExportFormat } from '../../components/Collections/collectionFile'
import { getApiErrorMessage } from '../../types'
import { canAddToCollection } from '../../utils/collectionAccess'
import { canAddToCollection, canRemoveFromCollection, collectionRoleOf } from '../../utils/collectionAccess'
import { addListener, removeListener } from '../../api/websocket'
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId } from '../../store/collectionStore'
@@ -200,13 +200,9 @@ export function useCollections() {
// "All saved" spans many lists (each enforced per-place on the server), so it
// stays permissive on the client; a viewer of a shared list loses add/edit/delete.
const currentUserId = useAuthStore(s => s.user?.id)
const myRole = useMemo(() => {
if (isOwner) return 'owner' as const
const me = members.find(m => m.user_id === currentUserId && !m.is_owner)
return me?.role ?? null
}, [isOwner, members, currentUserId])
const myRole = useMemo(() => collectionRoleOf(isOwner, members, currentUserId), [isOwner, members, currentUserId])
const canEdit = isAllSaved || myRole === 'owner' || myRole === 'admin' || myRole === 'editor'
const canDelete = isAllSaved || myRole === 'owner' || myRole === 'admin'
const canDelete = isAllSaved || canRemoveFromCollection(myRole)
// Sharing is offered on any real (non "All saved") list the user can see. The
// badge counts everyone but the owner — accepted collaborators + pending invites.
@@ -75,6 +75,15 @@ export interface TourPlannerMapStageProps
paddleChecklist?: PageState['paddleChecklist'];
}
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
function viewedTourOf(tourPlanner: PageState['tourPlanner']) {
const viewingGpx = tourPlanner.mode.type === 'view-gpx';
return {
analysis: viewingGpx ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis,
tourWaypoints: viewingGpx ? NO_POINTS : tourPlanner.waypoints,
};
}
/**
* The tour planner tab's map and the location search floating over it. It returns a
* fragment so both sit straight in the tab container, ahead of the side panels, with
@@ -105,7 +114,7 @@ export function TourPlannerMapStage({
() => [tourMap.focusPoints, ...(tourMap.route ?? [])],
[tourMap.focusPoints, tourMap.route]
);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const { analysis, tourWaypoints } = viewedTourOf(tourPlanner);
const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null);
// The zoom either map last reported, on the raster tile scale; unknown until the first one did.
const [mapZoom, setMapZoom] = useState<number | null>(null);
@@ -129,7 +138,6 @@ export function TourPlannerMapStage({
useEffect(() => () => publishTourMapViewport(null), []);
// The rails beside the map warn about the stretches that climb a ferrata, and band them on the profile.
const { t: translate, language } = useTranslation();
const tourWaypoints = tourPlanner.mode.type === 'view-gpx' ? NO_POINTS : tourPlanner.waypoints;
useEffect(() => {
const describe = (group: FerrataGroup) => {
const label = ferrataLabel(group, translate);
@@ -14,15 +14,45 @@ import { useTripStore } from '../../store/tripStore';
import { useTourDetailActions } from './useTourDetailActions';
import type { useTripPlanner } from './useTripPlanner';
type TripPlanner = ReturnType<typeof useTripPlanner>;
/**
* A tour of the trip opened in the planner: an import (no waypoints of its own) shows as it
* came in, a planned tour opens in the editor. Never over an edit it would replace.
*/
function useTourOpeners(
tourPlanner: ReturnType<typeof useTourPlanner>,
places: TripPlanner['places'],
tours: TripPlanner['tours']
) {
const { hasUnsavedChanges, viewGpxTour, openTour } = tourPlanner;
/** An import shown as it came in, over whatever the planner shows; the caller asked before discarding. */
const viewImportedTour = useCallback(
(tour: TourListItem) => {
// The store first: right after an import the page's list may not have the new place yet.
const byId = (item: { id: number }) => item.id === tour.place_id;
const place = useTripStore.getState().places.find(byId) ?? places.find(byId);
viewGpxTour(tour, place?.route_geometry ?? null);
},
[places, viewGpxTour]
);
const openTourInPlanner = useCallback(
(placeId: number, known?: TourListItem) => {
const tour = known ?? tours.find((item) => item.place_id === placeId);
if (!tour || hasUnsavedChanges) return;
if (tour.has_waypoints === false) viewImportedTour(tour);
else void openTour(tour);
},
[hasUnsavedChanges, openTour, tours, viewImportedTour]
);
return { viewImportedTour, openTourInPlanner };
}
/**
* The desktop tours workspace: the tour editor, its GPX import (button and file drop)
* and the wanderer trail browser, wired so that whatever comes in opens on the map.
*/
export function useTourWorkspace(
planner: ReturnType<typeof useTripPlanner>,
canPlaceEdit: boolean,
canDayEdit: boolean
) {
export function useTourWorkspace(planner: TripPlanner, canPlaceEdit: boolean, canDayEdit: boolean) {
const {
activeTab,
enabledAddons,
@@ -55,28 +85,7 @@ export function useTourWorkspace(
});
useTourRevisionWatch(tourPlanner, tours); // P2C-03: a save made elsewhere reaches the open editor
// A tour of the trip opened in the planner: an import (no waypoints of its own) shows as it
// came in, a planned tour opens in the editor. Never over an edit it would replace.
const { hasUnsavedChanges, viewGpxTour, openTour } = tourPlanner;
/** An import shown as it came in, over whatever the planner shows; the caller asked before discarding. */
const viewImportedTour = useCallback(
(tour: TourListItem) => {
// The store first: right after an import the page's list may not have the new place yet.
const byId = (item: { id: number }) => item.id === tour.place_id;
const place = useTripStore.getState().places.find(byId) ?? places.find(byId);
viewGpxTour(tour, place?.route_geometry ?? null);
},
[places, viewGpxTour]
);
const openTourInPlanner = useCallback(
(placeId: number, known?: TourListItem) => {
const tour = known ?? tours.find((item) => item.place_id === placeId);
if (!tour || hasUnsavedChanges) return;
if (tour.has_waypoints === false) viewImportedTour(tour);
else void openTour(tour);
},
[hasUnsavedChanges, openTour, tours, viewImportedTour]
);
const { viewImportedTour, openTourInPlanner } = useTourOpeners(tourPlanner, places, tours);
// The tours workspace's GPX import, shared by its button and its file drop. One new
// tour opens on the map at once.
@@ -15,6 +15,42 @@ import { usePlannerFocusPoints } from './usePlannerFocusPoints'
import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
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. */
function paddleLineOf(tourPlanner: TourPlanner) {
if (tourPlanner.mode.type === 'view-gpx') {
return tourPlanner.mode.tour.tour_type === 'kayak' ? tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? null : null
}
return tourPlanner.isPaddle ? tourPlanner.route : null
}
/**
* RTE-02, RTE-03: the ways offered over the route being edited (a section's
* ways being compared, else the round hikes), the one in focus, and the frame
* round hikes ask the map for while they are looked for and shown.
*/
function offeredWaysOf(tourPlanner: TourPlanner, offering: boolean) {
const { comparison, focus: variantFocus } = tourPlanner.legVariants
const { view: rounds, focus: roundFocus, frame: roundFrame } = tourPlanner.roundHikes
return {
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. */
function mapFocusKeyOf(
wandererBrowser: Workspace['wandererBrowser'],
frame: ReturnType<typeof offeredWaysOf>['frame'],
plannerKey: ReturnType<typeof useMapLocationSearch>['mapFocusKey'],
) {
if (wandererBrowser.open && wandererBrowser.selected) return `wanderer:${wandererBrowser.selected.id}`
return frame?.key ?? plannerKey
}
export function useTripPlannerPage() {
const planner = useTripPlanner()
const {
@@ -42,10 +78,7 @@ export function useTripPlannerPage() {
const trackParts = useTrackPortageParts(tourPlanner)
// PAD-05B: the obstacles around the paddle tour in view, for the map and the rails alike.
const paddleLine = tourPlanner.mode.type === 'view-gpx'
? (tourPlanner.mode.tour.tour_type === 'kayak' ? tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? null : null)
: tourPlanner.isPaddle ? tourPlanner.route : null
usePaddleObstacleSync(paddleLine, activeTab === 'tour-planner')
usePaddleObstacleSync(paddleLineOf(tourPlanner), activeTab === 'tour-planner')
const route = useMemo<[number, number][][] | null>(() => {
if (wandererBrowser.open) return wandererBrowser.line ? [wandererBrowser.line] : null
if (tourPlanner.mode.type === 'view-gpx') {
@@ -82,13 +115,8 @@ export function useTripPlannerPage() {
const ownLine = editingRoute || (tourPlanner.mode.type === 'view-gpx' && !tourConversion.line)
const highlighted = useHighlightedRoute(ownLine && !wandererBrowser.open, route, routeWalking, partStyle.colors)
// RTE-02, RTE-03: the ways of a section being compared, or the round hikes offered, over the route being edited.
const { comparison, focus: variantFocus } = tourPlanner.legVariants
const { view: rounds, focus: roundFocus, frame: roundFrame } = tourPlanner.roundHikes
const offering = editingRoute && !wandererBrowser.open
const offeredWays = offering ? comparison?.variants ?? rounds?.candidates ?? null : null
// RTE-03: while round hikes are looked for and shown, the map frames where they can lie.
const framedRounds = offering ? roundFrame : null
const drawn = useLegVariantLines(highlighted.route, highlighted.walking, highlighted.colors, offeredWays, comparison ? variantFocus : roundFocus)
const offered = offeredWaysOf(tourPlanner, editingRoute && !wandererBrowser.open)
const drawn = useLegVariantLines(highlighted.route, highlighted.walking, highlighted.colors, offered.ways, offered.focus)
const { closeGpxTour } = tourPlanner
useEffect(() => {
@@ -136,10 +164,9 @@ export function useTripPlannerPage() {
routeColors: drawn.colors,
variantLines: drawn.overlays,
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
focusPoints: framedRounds?.points ?? mapFocusPoints,
// A picked wanderer trail frames itself; the rest follows the planner.
focusKey:
wandererBrowser.open && wandererBrowser.selected ? `wanderer:${wandererBrowser.selected.id}` : framedRounds?.key ?? mapFocusKey, captureViewport,
focusPoints: offered.frame?.points ?? mapFocusPoints,
focusKey: mapFocusKeyOf(wandererBrowser, offered.frame, mapFocusKey),
captureViewport,
},
locationSearch,
plannedTourDurations,
+20 -4
View File
@@ -1,4 +1,4 @@
import type { Collection } from '@trek/shared';
import type { Collection, CollectionMember, CollectionRole } from '@trek/shared';
/**
* Whether the user may add to a list: their own, or a shared one where they
@@ -12,8 +12,24 @@ export function canAddToCollection(
if (collection.is_owner !== false) return true;
return (collection.members ?? []).some(
(member) =>
member.user_id === userId &&
member.status === 'accepted' &&
(member.role === 'editor' || member.role === 'admin')
member.user_id === userId && member.status === 'accepted' && (member.role === 'editor' || member.role === 'admin')
);
}
/**
* The user's role on a list: owner of their own, else the role a shared list
* gives them as a member, or null when they are neither.
*/
export function collectionRoleOf(
isOwner: boolean,
members: readonly Pick<CollectionMember, 'user_id' | 'is_owner' | 'role'>[],
userId: number | null | undefined
): 'owner' | CollectionRole | null {
if (isOwner) return 'owner';
return members.find((member) => member.user_id === userId && !member.is_owner)?.role ?? null;
}
/** Whether a role may remove from a list: its owner or an admin of it. */
export function canRemoveFromCollection(role: 'owner' | CollectionRole | null): boolean {
return role === 'owner' || role === 'admin';
}