feat(tours): check a city walk's stops off for the day it is on (CW-03)

The walk's detail opened from a day lists its stops in order, each with its
stay, importance and protection, and marks one visited or skipped for that
day, or takes it back. The day row shows how many were visited. Marks reach
the other travellers at once and change no route, place or order; a copy or
another day starts with none.
This commit is contained in:
Maurice
2026-10-19 22:38:03 +02:00
parent 4c594876ef
commit 290961cced
39 changed files with 618 additions and 3 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
export const tourVisitsApi = {
list: (tripId: number | string): Promise<TourVisitListResponse> =>
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
update: (
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient
.put(`/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`, body)
.then((r) => r.data),
};
+2
View File
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// CW-03 — components/Tours/walk/useTourVisitsSync keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { WalkProgressPill } from '../Tours/walk/WalkStations';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
import { TimePill } from './planParts';
@@ -19,6 +20,8 @@ interface DayPlanPlaceSublineProps {
tourTimes: DayRowTourTimes | undefined;
isTourPlace: boolean;
timeFormat: string;
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
assignmentId?: number;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
@@ -90,7 +93,7 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
// Second line of a place row in the day plan: time, tour facts or planned
// tour durations, then the clamped description.
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat, assignmentId }: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
const description = place.description || place.address || cat?.name;
@@ -106,6 +109,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
</TimePill>
)}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
);
@@ -2033,7 +2033,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
@@ -64,6 +64,7 @@ import {
} from './tourPresentation';
import { useTourDayAssignment } from './useTourDayAssignment';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
import { WalkStationsCard } from './walk/WalkStations';
interface TourDetailDialogProps extends TourPermissionProps {
tour: TourListItem;
@@ -652,6 +653,9 @@ export default function TourDetailDialog({
placeId={place.id}
/>
)}
{tour.tour_type === 'walk' && (
<WalkStationsCard tour={tour} tripId={place.trip_id} assignmentId={assignment?.id ?? null} canMark={canAssign} />
)}
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
@@ -0,0 +1,180 @@
import type { TourListItem, TourVisitStatus } from '@trek/shared';
import { Check, CheckCheck, Redo2, SkipForward, Undo2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { tourRepo } from '../../../repo/tourRepo';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { SoftPill } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { useToast } from '../../shared/Toast';
import { StationPills } from '../planner/stationParts';
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { TourDayRowPill } from '../TourDayRowFacts';
import { CardAction } from '../tourParts';
let nextId = 0;
const freshId = () => `station-${++nextId}`;
/** CW-03: a saved walk's stations in order, read once for the dialog; null while they load or could not be read. */
function useWalkStations(tripId: number | string, placeId: number): TourPlannerWaypoint[] | null {
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
useEffect(() => {
const controller = new AbortController();
tourRepo
.detail(tripId, placeId, controller.signal)
.then((detail) => {
if (controller.signal.aborted) return;
const points = plannerWaypointsOf(detail.waypoints, freshId).filter((point) => !point.shaping);
setStations({ placeId, points });
})
.catch(() => {
if (!controller.signal.aborted) setStations({ placeId, points: [] });
});
return () => controller.abort();
}, [placeId, tripId]);
return stations?.placeId === placeId ? stations.points : null;
}
/** The day's status of each station, by its key. */
function useDayVisits(assignmentId: number | null) {
return useTourVisitStore((state) => (assignmentId === null ? undefined : state.byAssignment[assignmentId]));
}
/**
* CW-03: a walk's progress on one of its days, as a pill of the day row:
* how many of its stations were visited. Nothing is shown before any is.
*/
export function WalkProgressPill({ tour, assignmentId }: { tour: TourListItem; assignmentId: number | undefined }) {
const { t } = useTranslation();
const visits = useDayVisits(assignmentId ?? null);
const total = tour.station_count ?? 0;
if (tour.tour_type !== 'walk' || total === 0 || !visits) return null;
const visited = Object.values(visits).filter((status) => status === 'visited').length;
if (visited === 0 && Object.keys(visits).length === 0) return null;
const label = t('tours.walk.progress', { visited, total });
return <TourDayRowPill icon={CheckCheck} value={`${visited}/${total}`} label={label} testId="plan-walk-progress" />;
}
function StatusActions({
status,
onMark,
}: {
status: TourVisitStatus | undefined;
onMark: (status: TourVisitStatus | 'planned') => void;
}) {
const { t } = useTranslation();
if (status)
return (
<CardAction icon={<Undo2 size={12} strokeWidth={2} />} onClick={() => onMark('planned')}>
{t('tours.walk.visit.undo')}
</CardAction>
);
return (
<span className="flex flex-wrap gap-1.5">
<CardAction icon={<Check size={12} strokeWidth={2.2} />} onClick={() => onMark('visited')}>
{t('tours.walk.visit.visited')}
</CardAction>
<CardAction icon={<SkipForward size={12} strokeWidth={2} />} onClick={() => onMark('skipped')}>
{t('tours.walk.visit.skip')}
</CardAction>
</span>
);
}
/**
* CW-03: a city walk's stations in its detail, in order, each with its stay,
* importance and protection; opened from a day, each can be marked visited or
* skipped for that day and taken back. Marking changes no route, place or order.
*/
export function WalkStationsCard({
tour,
tripId,
assignmentId,
canMark,
}: {
tour: TourListItem;
tripId: number | string;
/** The day the dialog was opened from; without one there is nothing to check off. */
assignmentId: number | null;
canMark: boolean;
}) {
const { t } = useTranslation();
const toast = useToast();
const stations = useWalkStations(tripId, tour.place_id);
const visits = useDayVisits(assignmentId);
const mark = useTourVisitStore((state) => state.mark);
if (tour.tour_type !== 'walk') return null;
const onMark = (key: string, status: TourVisitStatus | 'planned') => {
if (assignmentId === null) return;
mark(tripId, assignmentId, key, status).catch(() => toast.error(t('tours.walk.visit.failed')));
};
const visited = stations
? stations.filter((station) => station.key && visits?.[station.key] === 'visited').length
: 0;
return (
<DialogSection
className="flex-none"
label={t('tours.walk.stations')}
action={
stations && assignmentId !== null ? (
<SoftPill>{t('tours.walk.progress', { visited, total: stations.length })}</SoftPill>
) : undefined
}
>
<div data-testid="walk-stations" className="flex flex-col gap-2">
{stations === null && (
<span className="text-content-muted" style={fs(11.5, 'body')}>
{t('common.loading')}
</span>
)}
{assignmentId === null && stations && stations.length > 0 && (
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.walk.visit.fromDay')}
</span>
)}
<ol className="m-0 flex list-none flex-col gap-1.5 p-0">
{(stations ?? []).map((station, index) => {
const status = station.key ? visits?.[station.key] : undefined;
return (
<li
key={station.id}
data-testid="walk-station"
className={`flex items-start gap-2.5 rounded-[10px] px-1.5 py-1.5 ${status ? 'opacity-70' : ''}`}
>
<span
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-tertiary font-geist tabular-nums text-content-secondary"
style={fs(11)}
>
{status === 'visited' ? (
<Check size={12} strokeWidth={2.4} />
) : status === 'skipped' ? (
<Redo2 size={12} strokeWidth={2} />
) : (
index + 1
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span
className={`truncate font-semibold text-content ${status === 'skipped' ? 'line-through' : ''}`}
style={fs(12.5, 'body')}
>
{station.name ?? t('tours.walk.stationNumbered', { number: index + 1 })}
</span>
<StationPills point={station} />
{status && (
<span className="text-content-faint" style={fs(11, 'body')}>
{t(status === 'visited' ? 'tours.walk.visit.isVisited' : 'tours.walk.visit.isSkipped')}
</span>
)}
</span>
{canMark && assignmentId !== null && station.key && (
<StatusActions status={status} onMark={(next) => onMark(station.key ?? '', next)} />
)}
</li>
);
})}
</ol>
</div>
</DialogSection>
);
}
@@ -0,0 +1,39 @@
import { tourVisitSchema } from '@trek/shared';
import { useEffect } from 'react';
import { addListener, removeListener } from '../../../api/websocket';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import type { WebSocketEvent } from '../../../types';
/**
* CW-03: the visits of a trip's city walks, read when the trip opens and kept
* current by its socket, so a station another traveller checks off shows on
* every day plan at once. Offline the day plan shows none until it is back.
*/
export function useTourVisitsSync(tripId: number | string | undefined, enabled: boolean) {
useEffect(() => {
if (!enabled || tripId === undefined) return;
let current = true;
useTourVisitStore
.getState()
.load(tripId)
.catch(() => {
// Offline or refused, the day plan shows no progress rather than another trip's.
if (current) useTourVisitStore.getState().reset(tripId);
});
const onEvent = (event: WebSocketEvent) => {
if (event?.type !== 'tours:visits' || String(event.tripId ?? '') !== String(tripId)) return;
const visits = Array.isArray(event.visits)
? event.visits.flatMap((raw) => {
const parsed = tourVisitSchema.safeParse(raw);
return parsed.success ? [parsed.data] : [];
})
: [];
useTourVisitStore.getState().applyRemote(Number(event.assignmentId), visits);
};
addListener(onEvent);
return () => {
current = false;
removeListener(onEvent);
};
}, [enabled, tripId]);
}
@@ -1,3 +1,4 @@
import { useTourVisitsSync } from '../../components/Tours/walk/useTourVisitsSync'
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router'
@@ -284,6 +285,8 @@ export function useTripPlanner() {
const [toursMode, setToursMode] = useState(false)
const previousToursModeRef = useRef(false)
const { tours, toursLoading, tourDataReady, tourPlaceIds, reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour } = useTourPlaceIds(tripId, toursEnabled)
// CW-03: how far the trip's city walks got on their days, for the day plan and the walks' stations.
useTourVisitsSync(tripId, toursEnabled)
useEffect(() => {
if (!toursEnabled) {
+24
View File
@@ -0,0 +1,24 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import { tourVisitsApi } from '../api/tourVisits';
import { requireOnline } from './onlineOnly';
/**
* CW-03, online only for now: a station checked off is the server's at once,
* so everyone on the trip sees the same day. Offline the station says so and
* waits; the client keeps no copy of its own.
*/
export const tourVisitRepo = {
async list(tripId: number | string): Promise<TourVisitListResponse> {
requireOnline();
return await tourVisitsApi.list(tripId);
},
async update(
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> {
requireOnline();
return await tourVisitsApi.update(tripId, assignmentId, stationKey, body);
},
};
+68
View File
@@ -0,0 +1,68 @@
import type { TourVisit, TourVisitStatus } from '@trek/shared';
import { create } from 'zustand';
import { tourVisitRepo } from '../repo/tourVisitRepo';
/** A station's status on one day of its walk; a station not listed is planned. */
type VisitsByStation = Readonly<Record<string, TourVisitStatus>>;
interface TourVisitState {
/** The trip the visits were read for. */
tripId: string | null;
/** Per day assignment, the stations visited or skipped. */
byAssignment: Readonly<Record<number, VisitsByStation>>;
load: (tripId: number | string) => Promise<void>;
/** Nothing known for this trip: its visits could not be read. */
reset: (tripId: number | string) => void;
/** Marks a station, at once on screen; a write the server refuses puts it back and throws. */
mark: (
tripId: number | string,
assignmentId: number,
stationKey: string,
status: TourVisitStatus | 'planned'
) => Promise<void>;
/** Another traveller marked a station: the day's visits as the server has them now. */
applyRemote: (assignmentId: number, visits: readonly TourVisit[]) => void;
}
function byStation(visits: readonly TourVisit[]): VisitsByStation {
return Object.fromEntries(visits.map((visit) => [visit.station_key, visit.status]));
}
function withStatus(day: VisitsByStation | undefined, key: string, status: TourVisitStatus | 'planned') {
const next = { ...day };
if (status === 'planned') delete next[key];
else next[key] = status;
return next;
}
/**
* CW-03: how far each city walk got on its days, for the day plan's rows and
* the walk's stations. Read once a trip opens, kept current by the trip's
* socket, and written one station at a time.
*/
export const useTourVisitStore = create<TourVisitState>((set, get) => ({
tripId: null,
byAssignment: {},
load: async (tripId) => {
const { visits } = await tourVisitRepo.list(tripId);
const grouped: Record<number, Record<string, TourVisitStatus>> = {};
for (const visit of visits) (grouped[visit.assignment_id] ??= {})[visit.station_key] = visit.status;
set({ tripId: String(tripId), byAssignment: grouped });
},
reset: (tripId) => set({ tripId: String(tripId), byAssignment: {} }),
mark: async (tripId, assignmentId, stationKey, status) => {
const before = get().byAssignment[assignmentId];
set((state) => ({
byAssignment: { ...state.byAssignment, [assignmentId]: withStatus(before, stationKey, status) },
}));
try {
const { visits } = await tourVisitRepo.update(tripId, assignmentId, stationKey, { status });
get().applyRemote(assignmentId, visits);
} catch (failure) {
set((state) => ({ byAssignment: { ...state.byAssignment, [assignmentId]: before ?? {} } }));
throw failure;
}
},
applyRemote: (assignmentId, visits) =>
set((state) => ({ byAssignment: { ...state.byAssignment, [assignmentId]: byStation(visits) } })),
}));