Files
TREK/client/src/components/Tours/paddle/TrackPaddlePlanCard.tsx
T
Maurice 9cd227fd81 feat(tours): fold the rail cards, remembered across reloads; trip totals as badges
The planner's cards fold to their head: waypoints, the paddle time estimate,
the wind and the checklist on the left, the open tour's summary on the right.
The title or the chevron folds a card, and the choice is kept in this
browser, so a reload finds the rails as they were left. A folded card still
says what matters in its head: the checklist what is open, the wind its day,
and the time estimate now its total as a badge, in the warning tone while
parts of it are unknown. The "planning values" badge and info dot of the
estimate go; what the starting values are is said once under its figures.
The obstacle entry dialog sits outside the checklist's body, so an obstacle
picked on the map still opens with the card folded.

The trip totals start folded to their counts and show each type's sums as
the row's white badges, each named in its tooltip, an incomplete one marked
as at least that much with how many tours it is known for. The info text is
shorter.

The Tours locale contract counts the 574 general keys the planned cards
brought in.
2026-10-19 14:18:28 +02:00

185 lines
7.1 KiB
TypeScript

import type { TourListItem } from '@trek/shared';
import { MousePointerClick, Plus, RotateCcw, Save } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { TrackPick } from '../../../store/trackMarkToolStore';
import { formatDistance } from '../../../utils/units';
import { DialogButton, fs } from '../../shared/DialogShell';
import { RailCard, TourNotice } from '../tourParts';
import { PaddleGlyph } from './PaddleGlyph';
import { PaddleEstimateBody, PaddleTimeCardShell } from './PaddleTimeCard';
import { LockRow, PortageLegRow } from './PaddleWaypointParts';
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
/** One of the two tools: pressed while a click on the line places its mark. */
function PickButton({
active,
disabled,
onPress,
icon,
children,
testId,
}: {
active: boolean;
disabled: boolean;
onPress: () => void;
icon: ReactNode;
children: ReactNode;
testId: string;
}) {
return (
<button
type="button"
data-testid={testId}
aria-pressed={active}
disabled={disabled}
onClick={onPress}
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint enabled:hover:text-content'}`}
style={fs(11.5, 'body')}
>
<Plus size={12} strokeWidth={2.6} className="flex-none" />
<span className="flex-none">{icon}</span>
<span className="min-w-0 truncate">{children}</span>
</button>
);
}
/** What a click on the line does now, or why the last one placed nothing. */
function pickHint(plan: TrackPaddlePlan, t: (key: string) => string): string | null {
if (plan.refused === 'overlap') return t('tours.paddle.marks.overlap');
if (plan.refused === 'off_line') return t('tours.paddle.marks.offLine');
if (plan.pick === 'lock') return t('tours.paddle.marks.pickLock');
if (plan.pick === 'portage')
return t(plan.pendingFromM === null ? 'tours.paddle.marks.pickTakeOut' : 'tours.paddle.marks.pickReEntry');
return null;
}
/**
* The portages and locks laid onto a recorded paddle track: two tools to mark
* them by clicking the line, the list of what is marked with its times, and
* save once something changed. The recorded track is never redrawn.
*/
function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const at = (m: number) => t('tours.paddle.marks.at', { distance: formatDistance(m / 1000, unit) });
const locked = !plan.canEdit || plan.saving;
const hint = pickHint(plan, t);
const pick = (kind: TrackPick) => () => plan.startPick(kind);
const empty = plan.marks.portages.length === 0 && plan.marks.locks.length === 0;
return (
<RailCard icon={<PaddleGlyph kind="portage" size={14} />} title={t('tours.paddle.marks.title')}>
<div data-testid="track-marks-card" className="flex flex-col gap-2.5">
{plan.canEdit && (
<div className="flex gap-1.5">
<PickButton
testId="track-marks-pick-portage"
active={plan.pick === 'portage'}
disabled={locked}
onPress={pick('portage')}
icon={<PaddleGlyph kind="portage" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markPortage')}
</PickButton>
<PickButton
testId="track-marks-pick-lock"
active={plan.pick === 'lock'}
disabled={locked}
onPress={pick('lock')}
icon={<PaddleGlyph kind="lock" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markLock')}
</PickButton>
</div>
)}
{hint && (
<TourNotice
tone={plan.refused ? 'warning' : 'neutral'}
icon={<MousePointerClick size={13} strokeWidth={2} />}
>
{hint}
</TourNotice>
)}
{empty ? (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.paddle.marks.empty')}
</p>
) : (
<ul className="m-0 -ms-[34px] flex list-none flex-col p-0">
{plan.marks.portages.map((portage, index) => (
<PortageLegRow
key={`p-${portage.from_m}`}
lengthM={portage.to_m - portage.from_m}
extraMinutes={portage.extra_minutes}
disabled={locked}
position={at(portage.from_m)}
onExtraChange={(minutes) => plan.edits.setPortageExtra(index, minutes)}
onRemove={() => plan.edits.removePortage(index)}
/>
))}
{plan.marks.locks.map((lock, index) => (
<LockRow
key={`l-${lock.at_m}-${index}`}
lock={lock}
bypassed={!!plan.layout.locks.find((stop) => stop.atM === lock.at_m)?.bypassed}
disabled={locked}
position={at(lock.at_m)}
onChange={(next) => plan.edits.setLock(index, next)}
/>
))}
</ul>
)}
{plan.dirty && plan.canEdit && (
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={plan.discard} disabled={plan.saving}>
{t('common.discard')}
</DialogButton>
<DialogButton
variant="primary"
icon={<Save size={14} strokeWidth={2} />}
onClick={() => void plan.save()}
disabled={plan.saving}
>
{t('common.save')}
</DialogButton>
</div>
)}
</div>
</RailCard>
);
}
/**
* PAD-03 and PAD-07 on a recorded paddle track: its time estimate, reckoned
* with the portages and locks laid onto it, and the card to lay them.
*/
export function TrackPaddlePlanCards({ plan, tour }: { plan: TrackPaddlePlan; tour: TourListItem }) {
if (!plan.active) return null;
const locked = !plan.canEdit || plan.saving;
return (
<>
<PaddleTimeCardShell estimate={plan.estimate} breakMinutes={tour.break_additional_minutes ?? null}>
<PaddleEstimateBody
estimate={plan.estimate}
layout={plan.layout}
breakMinutes={tour.break_additional_minutes ?? null}
planned={tour.planned_duration_minutes ?? null}
locked={locked}
portageSpeed={{
value: plan.speeds.portage,
startValue: plan.defaultPortageSpeed,
onChange: (portage) => plan.edits.setSpeeds({ portage }),
}}
paddleSpeed={{
value: plan.speeds.paddle,
startValue: plan.recommendedPaddleSpeed,
onChange: (paddle) => plan.edits.setSpeeds({ paddle }),
}}
/>
</PaddleTimeCardShell>
<TrackMarksCard plan={plan} />
</>
);
}