-
+ } label={t('tours.provenance.source')}>
{provenance.takenOverFrom && (
diff --git a/client/src/components/Tours/run/RunWaysCard.test.tsx b/client/src/components/Tours/run/RunWaysCard.test.tsx
index a420034b6..9af554d34 100644
--- a/client/src/components/Tours/run/RunWaysCard.test.tsx
+++ b/client/src/components/Tours/run/RunWaysCard.test.tsx
@@ -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', () => ({
diff --git a/client/src/components/Tours/run/RunWaysCard.tsx b/client/src/components/Tours/run/RunWaysCard.tsx
index c6b16f75f..c43ac481a 100644
--- a/client/src/components/Tours/run/RunWaysCard.tsx
+++ b/client/src/components/Tours/run/RunWaysCard.tsx
@@ -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
;
-
-/** 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.
diff --git a/client/src/components/Tours/ski/SkiClassificationFields.tsx b/client/src/components/Tours/ski/SkiClassificationFields.tsx
index 921844634..aaae3c3ee 100644
--- a/client/src/components/Tours/ski/SkiClassificationFields.tsx
+++ b/client/src/components/Tours/ski/SkiClassificationFields.tsx
@@ -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
diff --git a/client/src/components/Tours/ski/SkiTrackCard.tsx b/client/src/components/Tours/ski/SkiTrackCard.tsx
index 5d646e229..80999a092 100644
--- a/client/src/components/Tours/ski/SkiTrackCard.tsx
+++ b/client/src/components/Tours/ski/SkiTrackCard.tsx
@@ -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';
/**
diff --git a/client/src/components/Tours/ski/skiDiscipline.ts b/client/src/components/Tours/ski/skiDiscipline.ts
new file mode 100644
index 000000000..458d0bfa2
--- /dev/null
+++ b/client/src/components/Tours/ski/skiDiscipline.ts
@@ -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 = {
+ alpine: CableCar,
+ snowboard: Snowboard,
+ cross_country: Spline,
+ touring: TrendingUp,
+};
+
+export type SkiTour = Partial>;
+
+/** 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;
+}
diff --git a/client/src/components/Tours/ski/skiMarks.test.tsx b/client/src/components/Tours/ski/skiMarks.test.tsx
index 02e9bb768..60ad71767 100644
--- a/client/src/components/Tours/ski/skiMarks.test.tsx
+++ b/client/src/components/Tours/ski/skiMarks.test.tsx
@@ -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
diff --git a/client/src/components/Tours/ski/skiMarks.tsx b/client/src/components/Tours/ski/skiMarks.tsx
index fcec7511c..429276589 100644
--- a/client/src/components/Tours/ski/skiMarks.tsx
+++ b/client/src/components/Tours/ski/skiMarks.tsx
@@ -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 = {
- alpine: CableCar,
- snowboard: Snowboard,
- cross_country: Spline,
- touring: TrendingUp,
-};
-
-type SkiTour = Partial>;
-
-/** 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
diff --git a/client/src/components/Tours/stages/StageParts.tsx b/client/src/components/Tours/stages/StageParts.tsx
index 74397bce4..7bcff83c4 100644
--- a/client/src/components/Tours/stages/StageParts.tsx
+++ b/client/src/components/Tours/stages/StageParts.tsx
@@ -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['t'];
-type MenuItems = Parameters['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 (
,
- placeNames: Readonly>,
- 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')}
>
{from}
-
+
{to}
@@ -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
diff --git a/client/src/components/Tours/stages/TourStageDayPills.tsx b/client/src/components/Tours/stages/TourStageDayPills.tsx
index f1a5414f3..ae3d19938 100644
--- a/client/src/components/Tours/stages/TourStageDayPills.tsx
+++ b/client/src/components/Tours/stages/TourStageDayPills.tsx
@@ -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, 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
diff --git a/client/src/components/Tours/stages/TourStageOverview.tsx b/client/src/components/Tours/stages/TourStageOverview.tsx
index cdb8c0d39..abc45e0f3 100644
--- a/client/src/components/Tours/stages/TourStageOverview.tsx
+++ b/client/src/components/Tours/stages/TourStageOverview.tsx
@@ -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 (
- }>{value}
+ }>
+ {value}
+
);
@@ -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 (
-
-
+
+
{t('tours.stages.wholeTour')}
-
formatDistance(value, unit)} t={t} />
+ formatDistance(value, unit)}
+ t={t}
+ />
{saved.status === 'error' && {t('tours.stages.loadFailed')}}
{saved.status === 'loading' && (
-
+
{t('common.loading')}
@@ -156,7 +189,12 @@ export function TourStageOverview({ id, tripId, tour, days, canAssign, onOpenSta
<>
{saved.stages.map((stage) => (
- -
+
-
,
- 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={}
>
-
+
{stages.map((stage) => (
-
diff --git a/client/src/components/Tours/stages/stageActions.ts b/client/src/components/Tours/stages/stageActions.ts
new file mode 100644
index 000000000..ad142535f
--- /dev/null
+++ b/client/src/components/Tours/stages/stageActions.ts
@@ -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['t'];
+type MenuItems = Parameters['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,
+ 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;
+}
diff --git a/client/src/components/Tours/stages/stageNames.ts b/client/src/components/Tours/stages/stageNames.ts
new file mode 100644
index 000000000..11420169b
--- /dev/null
+++ b/client/src/components/Tours/stages/stageNames.ts
@@ -0,0 +1,25 @@
+import type { TourListItem } from '@trek/shared';
+import type { useTranslation } from '../../../i18n';
+
+type Translate = ReturnType['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>,
+ 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, stageKey: string): number | null {
+ const index = (tour.stage_stops ?? []).slice(0, -1).findIndex((stop) => stop.key === stageKey);
+ return index >= 0 ? index + 1 : null;
+}
diff --git a/client/src/components/Tours/stages/stageOverview.test.tsx b/client/src/components/Tours/stages/stageOverview.test.tsx
index 856031409..ba7a370f9 100644
--- a/client/src/components/Tours/stages/stageOverview.test.tsx
+++ b/client/src/components/Tours/stages/stageOverview.test.tsx
@@ -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';
diff --git a/client/src/components/Tours/stages/stageViews.test.tsx b/client/src/components/Tours/stages/stageViews.test.tsx
index f36873fe9..e37208bb6 100644
--- a/client/src/components/Tours/stages/stageViews.test.tsx
+++ b/client/src/components/Tours/stages/stageViews.test.tsx
@@ -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' }) }));
diff --git a/client/src/components/Tours/stages/useTourStageRail.ts b/client/src/components/Tours/stages/useTourStageRail.ts
index 0076423b4..5ce97e5d5 100644
--- a/client/src/components/Tours/stages/useTourStageRail.ts
+++ b/client/src/components/Tours/stages/useTourStageRail.ts
@@ -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) =>
export function useTourStageRail(planner: RailPlanner, openTour: (tour: TourListItem) => void) {
const [unfolded, setUnfolded] = useState>(() => new Set());
const [pending, setPending] = useState(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(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 };
diff --git a/client/src/components/Tours/wanderer/WandererHintsDialog.tsx b/client/src/components/Tours/wanderer/WandererHintsDialog.tsx
index 7d1541dbe..246454df8 100644
--- a/client/src/components/Tours/wanderer/WandererHintsDialog.tsx
+++ b/client/src/components/Tours/wanderer/WandererHintsDialog.tsx
@@ -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. */
diff --git a/client/src/components/Tours/wanderer/hintWhen.ts b/client/src/components/Tours/wanderer/hintWhen.ts
new file mode 100644
index 000000000..2ec38e119
--- /dev/null
+++ b/client/src/components/Tours/wanderer/hintWhen.ts
@@ -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' });
+}
diff --git a/client/src/components/Tours/wanderer/sourceHintParts.test.tsx b/client/src/components/Tours/wanderer/sourceHintParts.test.tsx
index 906aac131..f42542c3a 100644
--- a/client/src/components/Tours/wanderer/sourceHintParts.test.tsx
+++ b/client/src/components/Tours/wanderer/sourceHintParts.test.tsx
@@ -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)', () => {
diff --git a/client/src/components/Tours/wanderer/sourceHintParts.tsx b/client/src/components/Tours/wanderer/sourceHintParts.tsx
index 52365a1b8..8f9d109df 100644
--- a/client/src/components/Tours/wanderer/sourceHintParts.tsx
+++ b/client/src/components/Tours/wanderer/sourceHintParts.tsx
@@ -53,11 +53,3 @@ export function SourceHintTile({ state }: { state: TourSourceHintState }) {
);
}
-
-/** 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' });
-}
diff --git a/client/src/components/Tours/winter/winterActivityIcon.ts b/client/src/components/Tours/winter/winterActivityIcon.ts
new file mode 100644
index 000000000..2b1d148b9
--- /dev/null
+++ b/client/src/components/Tours/winter/winterActivityIcon.ts
@@ -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 = {
+ winter_hike: Snowflake,
+ snowshoe: Footprints,
+};
diff --git a/client/src/components/Tours/winter/winterMarks.tsx b/client/src/components/Tours/winter/winterMarks.tsx
index e47d5ef8d..3047791dd 100644
--- a/client/src/components/Tours/winter/winterMarks.tsx
+++ b/client/src/components/Tours/winter/winterMarks.tsx
@@ -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 = {
- 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
diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx
index 60b644910..30eab96e8 100644
--- a/client/src/pages/CollectionsPage.tsx
+++ b/client/src/pages/CollectionsPage.tsx
@@ -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' && (
-
- )}
+ {isRealList && }
{body}
>
)}
diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts
index 31ba6a4b3..fe1e12b82 100644
--- a/client/src/pages/collections/useCollections.ts
+++ b/client/src/pages/collections/useCollections.ts
@@ -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.
diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
index 138c767af..c42c1f177 100644
--- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
+++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
@@ -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(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);
diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts
index 39b33c581..66a9ea28c 100644
--- a/client/src/pages/tripPlanner/useTourWorkspace.ts
+++ b/client/src/pages/tripPlanner/useTourWorkspace.ts
@@ -14,15 +14,45 @@ import { useTripStore } from '../../store/tripStore';
import { useTourDetailActions } from './useTourDetailActions';
import type { useTripPlanner } from './useTripPlanner';
+type TripPlanner = ReturnType;
+
+/**
+ * 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,
+ 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,
- 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.
diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts
index 87020447d..7001219d9 100644
--- a/client/src/pages/tripPlanner/useTripPlannerPage.ts
+++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts
@@ -15,6 +15,42 @@ import { usePlannerFocusPoints } from './usePlannerFocusPoints'
import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
+type Workspace = ReturnType
+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['frame'],
+ plannerKey: ReturnType['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,
diff --git a/client/src/utils/collectionAccess.ts b/client/src/utils/collectionAccess.ts
index 0b49cd485..6c244260c 100644
--- a/client/src/utils/collectionAccess.ts
+++ b/client/src/utils/collectionAccess.ts
@@ -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[],
+ 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';
+}