feat(tours): show a city walk's progress on the phone's day row, and check its stops off offline (CW-03)

The day row on the phone now shows how many stops of a city walk were
visited on that day, "2 of 5 visited", like the desktop row does. Both rows
read it from one place, and a tap on the phone names it in full without
opening the row.

A walk's stops can be checked off, skipped and taken back without a
connection. The mark shows at once, waits in the offline queue and goes out
when the connection is back, in the order the marks were made and behind any
older mark of the same day that still waits. The day plan, the walk's detail
and a reload read the visits kept on this device with the waiting marks on
top, so a mark made offline stays on screen until the server has it. A mark
the server answers with "not found", because the stop, the day or the walk
was removed meanwhile, counts as done and holds back no later mark of that
day. Any other refusal, the Tours addon turned off among them, keeps the mark
as a failed change to try again from the offline settings. The visits are
read again when the connection comes and goes, and once more after the
waiting marks went out, so a mark the server dropped or refused leaves the
day plan. A trip's visits that answer after another trip was opened no longer
show there.

The offline sync keeps each trip's walk visits and reads the stops of a walk
this device has never opened, so it can be checked off offline too. A walk
whose stops this device could not read lists no stand-in stops and no count,
and says to open it again while online. A walk put on a day while offline
says its stops can be checked off once it has synced, instead of offering
buttons that cannot work yet. A mark that fails says to open the walk again
and try once more, in every language.

Marks carry no version, so the last one to reach the server wins, as with
the other changes sent after being offline. The offline guide says so.
This commit is contained in:
Maurice
2026-10-20 02:14:12 +00:00
parent 6bd19e2294
commit 8c93dc8d9c
56 changed files with 1884 additions and 122 deletions
+6 -3
View File
@@ -1,6 +1,11 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
}
/** 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> =>
@@ -11,7 +16,5 @@ export const tourVisitsApi = {
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient
.put(`/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`, body)
.then((r) => r.data),
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
};
+1 -1
View File
@@ -19,7 +19,7 @@ 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.
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
@@ -1,8 +1,9 @@
import type { TourListItem } from '@trek/shared';
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { paddleBench } from '../../../tests/helpers/paddleCheck';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useTourVisitStore } from '../../store/tourVisitStore';
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
import { tourDayRowIcon } from './tourDayRowIcon';
@@ -153,6 +154,42 @@ describe("CW-02 a city walk's time on its day row", () => {
});
});
describe("CW-03 a city walk's progress among the facts of a day row", () => {
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
beforeEach(() =>
useTourVisitStore.setState({
tripId: '4',
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } },
})
);
it.each([false, true])('shows the stops visited on that day, a tap naming them whole (mobile=%s)', (mobile) => {
const openRow = vi.fn();
render(
<div onClick={openRow}>
<TourDayRowFacts tour={walk} assignmentId={9} mobile={mobile} />
</div>
);
const progress = screen.getByTestId('plan-walk-progress');
expect(progress).toHaveTextContent('1/5');
expect(progress).toHaveAccessibleName('1 of 5 visited');
fireEvent.click(progress);
expect(screen.getByRole('note')).toHaveTextContent('1 of 5 visited');
expect(openRow).not.toHaveBeenCalled();
});
it('shows none without a day, or before a stop of that day is marked', () => {
render(
<>
<TourDayRowFacts tour={walk} mobile />
<TourDayRowFacts tour={walk} assignmentId={10} mobile />
</>
);
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
});
});
describe('the compact facts of a tour list row', () => {
const testIds = () =>
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
@@ -1,5 +1,5 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
@@ -11,6 +11,7 @@ import { paddleCheckFact } from './paddle/paddleCheckModel';
import { tourDayRowIcon } from './tourDayRowIcon';
import { movingTimeLabel } from './tourFacts';
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
import { useWalkProgress } from './walk/walkProgress';
import { walkStaysText } from './walk/walkTimeFields';
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
@@ -206,9 +207,19 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
}
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
export default function TourDayRowFacts({
tour,
assignmentId,
mobile = false,
}: {
tour: TourListItem;
/** The row's day assignment, for a city walk's progress on that day (CW-03). */
assignmentId?: number;
mobile?: boolean;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
const progress = useWalkProgress(tour, assignmentId);
const times = tourPlannedTimes(tour);
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
@@ -246,6 +257,15 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
{facts.map((fact) => (
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
))}
{progress && (
<Fact
icon={CheckCheck}
value={`${progress.visited}/${progress.total}`}
label={t('tours.walk.progress', progress)}
testId="plan-walk-progress"
mobile={mobile}
/>
)}
</span>
);
}
@@ -0,0 +1,289 @@
// CW-03, CW-04: a city walk's stops in its detail, checked off for the day it
// was opened from, and its progress as a pill of the desktop day row.
import type { TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { tourRepo } from '../../../repo/tourRepo';
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { ToastContainer } from '../../shared/Toast';
import { WalkProgressPill, WalkStationsCard, type WalkDay } from './WalkStations';
const walk: TourListItem = {
place_id: 42,
name: 'Old town',
tour_type: 'walk',
distance: 0.3,
elevation_gain: null,
elevation_loss: null,
duration: 20,
break_additional_minutes: null,
planned_duration_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: true,
caution: false,
station_count: 3,
};
const waypoints: TourWaypoint[] = [
{
lat: 48,
lng: 11,
role: 'start',
sequence: 0,
key: 'stop-01',
name: 'Museum',
stay_minutes: 30,
opening_hours: 'Mo-Fr 09:00-09:20',
},
{ lat: 48.0005, lng: 11, role: 'via', sequence: 1, key: 'shape-01', shaping: true },
{ lat: 48.001, lng: 11, role: 'via', sequence: 2, key: 'stop-02', opening_hours: 'sunrise-sunset' },
{ lat: 48.002, lng: 11, role: 'end', sequence: 3, name: 'Cafe', stay_minutes: 15, opening_hours: 'Mo 10:00-18:00' },
];
const day: WalkDay = {
date: '2026-10-19',
start: '09:00',
route: [
[48, 11],
[48.001, 11],
[48.002, 11],
],
walkingMinutes: 20,
};
function detail(points: TourWaypoint[] = waypoints): Promise<TourDetailResponse> {
return Promise.resolve({ tour: walk, waypoints: points });
}
function card(props: Partial<Parameters<typeof WalkStationsCard>[0]> = {}) {
return render(
<>
<ToastContainer />
<WalkStationsCard tour={walk} tripId={4} assignmentId={9} canMark {...props} />
</>
);
}
const UNREAD = 'The stops of this walk could not be loaded. Open it again while online to see them and check them off.';
const rowOf = async (name: string) =>
(await screen.findAllByTestId('walk-station')).find((row) => row.textContent?.includes(name)) as HTMLElement;
beforeEach(() => {
useTourVisitStore.setState({ tripId: '4', byAssignment: {} });
vi.spyOn(tourRepo, 'detail').mockImplementation(() => detail());
});
afterEach(() => vi.restoreAllMocks());
describe('WalkStationsCard', () => {
it('shows nothing for a tour that is no city walk', () => {
render(<WalkStationsCard tour={{ ...walk, tour_type: 'hike' }} tripId={4} assignmentId={9} canMark />);
expect(screen.queryByTestId('walk-stations')).not.toBeInTheDocument();
});
it('says it is loading until the stops are read', () => {
vi.mocked(tourRepo.detail).mockReturnValue(new Promise(() => undefined));
card();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('lists the stops in their order without the shaping points, an unnamed one by its number', async () => {
card();
const rows = await screen.findAllByTestId('walk-station');
expect(rows.map((row) => row.querySelector('.font-semibold')?.textContent)).toEqual(['Museum', 'Stop 2', 'Cafe']);
expect(tourRepo.detail).toHaveBeenCalledWith(4, 42, expect.any(AbortSignal));
});
it('opened without a day, offers nothing to check off and says where to', async () => {
card({ assignmentId: null });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
).toBeInTheDocument();
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
});
it('checks a stop off for its day and skips another, through the store', async () => {
const update = vi
.spyOn(tourVisitRepo, 'update')
.mockResolvedValueOnce({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] })
.mockResolvedValueOnce({
visits: [
{ assignment_id: 9, station_key: 'stop-01', status: 'visited' },
{ assignment_id: 9, station_key: 'stop-02', status: 'skipped' },
],
});
card();
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-01', { status: 'visited' });
expect(await within(await rowOf('Museum')).findByText('Visited on this day')).toBeInTheDocument();
fireEvent.click(within(await rowOf('Stop 2')).getByText('Skip'));
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-02', { status: 'skipped' });
expect(await within(await rowOf('Stop 2')).findByText('Skipped on this day')).toBeInTheDocument();
});
it('a stop visited on that day says so and can be taken back', async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
const update = vi.spyOn(tourVisitRepo, 'update').mockResolvedValue({ visits: [] });
card();
const museum = await rowOf('Museum');
expect(within(museum).getByText('Visited on this day')).toBeInTheDocument();
fireEvent.click(within(museum).getByText('Take back'));
expect(update).toHaveBeenCalledWith(4, 9, 'stop-01', { status: 'planned' });
});
it('a stop without a key has nothing to check off, and without the right to plan the day no stop has', async () => {
const { unmount } = card();
expect(within(await rowOf('Cafe')).queryByRole('button')).not.toBeInTheDocument();
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
unmount();
card({ canMark: false });
await screen.findAllByTestId('walk-station');
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
});
it('on a day put on the plan offline, says the stops wait for the sync and offers no buttons', async () => {
card({ assignmentId: -1700000000000 });
await screen.findAllByTestId('walk-station');
expect(
screen.getByText('This walk was put on the day while offline. Its stops can be checked off once it has synced.')
).toBeInTheDocument();
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
expect(screen.queryByText('Open the walk from a day of the plan to check its stops off for that day.')).toBeNull();
});
it('a mark the server refuses is taken back and said in a toast', async () => {
vi.spyOn(tourVisitRepo, 'update').mockRejectedValue(new Error('No permission'));
card();
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
expect(
await screen.findByText('The stop could not be marked. Open the walk again and try once more.')
).toBeInTheDocument();
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
});
it('heads the stops with how many were visited on that day', async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } } });
card();
expect(await screen.findByText('1 of 3 visited')).toBeInTheDocument();
});
it('on a day with a start, shows each arrival and what the opening hours say about it', async () => {
card({ day });
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00')).toBeInTheDocument();
expect(within(await rowOf('Museum')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Arrival 09:00, stay until 09:30, but it closes at 09:20'
);
expect(within(await rowOf('Stop 2')).getByText('Arrival about 09:40')).toBeInTheDocument();
expect(within(await rowOf('Stop 2')).getByText('Opening hours not read here: sunrise-sunset')).toBeInTheDocument();
expect(within(await rowOf('Cafe')).getByText('Arrival 09:50 at the earliest')).toBeInTheDocument();
expect(within(await rowOf('Cafe')).getByTestId('walk-station-conflict')).toHaveTextContent(
'Closed at 09:50 by its opening hours'
);
expect(screen.getByText(/Opening hours as OpenStreetMap has them/)).toBeInTheDocument();
});
it('on a day without a start, asks for one and leaves the hours unsaid', async () => {
card({ day: { ...day, start: null } });
expect(
await screen.findByText('Give the walk a time on this day to see arrivals and opening hours.')
).toBeInTheDocument();
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
expect(screen.queryByText(/Opening hours as OpenStreetMap has them/)).not.toBeInTheDocument();
});
it('names no source of hours when no stop has any', async () => {
vi.mocked(tourRepo.detail).mockImplementation(() =>
detail(waypoints.map(({ opening_hours: _hours, ...point }) => point))
);
card({ day });
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00')).toBeInTheDocument();
expect(screen.queryByText(/Opening hours as OpenStreetMap has them/)).not.toBeInTheDocument();
});
it('a walk whose stops cannot be read lists none, counts none and says so', async () => {
vi.mocked(tourRepo.detail).mockRejectedValue(new Error('Tour not available offline'));
card();
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
});
it("offline, the route's two ends read in place of the stops are no stops: no count, and it says so", async () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'visited' } } });
const ends: TourWaypoint[] = [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.002, lng: 11, role: 'end', sequence: 1 },
];
vi.mocked(tourRepo.detail).mockImplementation(() => detail(ends));
card({ day });
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
});
it('a walk saved offline lists its stops before the sync gave them keys', async () => {
const unsaved = waypoints.map(({ key: _key, ...point }) => point);
vi.mocked(tourRepo.detail).mockImplementation(() => detail(unsaved));
card({ tour: { ...walk, place_id: -1700000000001 }, assignmentId: -1700000000000 });
expect(await screen.findAllByTestId('walk-station')).toHaveLength(3);
expect(screen.getByText('0 of 3 visited')).toBeInTheDocument();
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
});
it('a walk without stops lists none and misses nothing', async () => {
vi.mocked(tourRepo.detail).mockImplementation(() => detail([]));
card({ tour: { ...walk, station_count: 0 } });
await vi.waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
});
});
describe('WalkProgressPill', () => {
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
render(
<>
<WalkProgressPill tour={{ ...walk, tour_type: 'hike' }} assignmentId={9} />
<WalkProgressPill tour={{ ...walk, station_count: 0 }} assignmentId={9} />
<WalkProgressPill tour={walk} assignmentId={10} />
<WalkProgressPill tour={walk} assignmentId={undefined} />
</>
);
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
});
it('shows the visited stops of all of them, named in full', () => {
useTourVisitStore.setState({
byAssignment: { 9: { a1b2c3: 'visited', d4e5f6: 'visited', g7h8i9: 'skipped' }, 10: { a1b2c3: 'skipped' } },
});
render(
<>
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={9} />
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={10} />
</>
);
const [day9, day10] = screen.getAllByTestId('plan-walk-progress');
expect(day9).toHaveTextContent('2/5');
expect(day9).toHaveAccessibleName('2 of 5 visited');
expect(day10).toHaveTextContent('0/5');
});
});
@@ -11,12 +11,26 @@ import { StationPills } from '../planner/stationParts';
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { TourDayRowPill } from '../TourDayRowFacts';
import { CardAction, TourNotice } from '../tourParts';
import { useDayVisits, useWalkProgress } from './walkProgress';
import { clockOf, minutesOf, stationArrivals, stationFit, type StationFit } from './walkSchedule';
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. */
/**
* CW-03: a synced walk's stations all carry a key. Points without any are the
* route's ends, which this device reads in their place while it holds none of
* the stations, so they are not listed as the walk's. A walk saved offline has
* no keys before it synced, and its stations stay.
*/
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
return placeId < 0 || points.some((point) => point.key) ? points : [];
}
/**
* CW-03: a saved walk's stations in order, read once for the dialog; null while
* they load, none where they could not be read.
*/
function useWalkStations(tripId: number | string, placeId: number): TourPlannerWaypoint[] | null {
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
useEffect(() => {
@@ -26,7 +40,7 @@ function useWalkStations(tripId: number | string, placeId: number): TourPlannerW
.then((detail) => {
if (controller.signal.aborted) return;
const points = walkStationsOf(plannerWaypointsOf(detail.waypoints, freshId));
setStations({ placeId, points });
setStations({ placeId, points: ownStations(points, placeId) });
})
.catch(() => {
if (!controller.signal.aborted) setStations({ placeId, points: [] });
@@ -36,24 +50,32 @@ function useWalkStations(tripId: number | string, placeId: number): TourPlannerW
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" />;
const progress = useWalkProgress(tour, assignmentId);
if (!progress) return null;
return (
<TourDayRowPill
icon={CheckCheck}
value={`${progress.visited}/${progress.total}`}
label={t('tours.walk.progress', progress)}
testId="plan-walk-progress"
/>
);
}
/**
* Whether the stations can be checked off from here: not without a day or the
* right to plan it, and not yet on a day put on the plan offline, which the
* server does not know until it synced.
*/
function markState(canMark: boolean, assignmentId: number | null): 'none' | 'ready' | 'notSynced' {
if (!canMark || assignmentId === null) return 'none';
return assignmentId < 0 ? 'notSynced' : 'ready';
}
function StatusActions({
@@ -177,19 +199,26 @@ function StationRow({
);
}
/** The lines above the stations: loading, what is missing for arrivals, where the hours come from, where to check off. */
/**
* The lines above the stations: loading, stations that could not be read, what
* is missing for arrivals, where the hours come from, where to check off.
*/
function WalkStationsNotes({
loading,
unread,
any,
noStart,
hoursShown,
fromDay,
notSynced,
}: {
loading: boolean;
unread: boolean;
any: boolean;
noStart: boolean;
hoursShown: boolean;
fromDay: boolean;
notSynced: boolean;
}) {
const { t } = useTranslation();
const quiet = (text: string) => (
@@ -200,9 +229,11 @@ function WalkStationsNotes({
if (loading) return quiet(t('common.loading'));
return (
<>
{unread && quiet(t('tours.walk.stationsUnread'))}
{any && noStart && quiet(t('tours.walk.hours.noStart'))}
{hoursShown && <TourNotice icon={<Clock size={13} strokeWidth={2} />}>{t('tours.walk.hours.source')}</TourNotice>}
{any && fromDay && quiet(t('tours.walk.visit.fromDay'))}
{any && notSynced && quiet(t('tours.walk.visit.notSynced'))}
</>
);
}
@@ -249,29 +280,33 @@ export function WalkStationsCard({
};
const start = minutesOf(day?.start);
const arrivals = stations && day?.date ? stationArrivals(stations, day.route, start, day.walkingMinutes) : null;
const anyHours = stations?.some((station) => station.openingHours) === true;
const visited = (stations ?? []).filter((station) => station.key && visits?.[station.key] === 'visited').length;
const canCheck = canMark && assignmentId !== null;
const listed = stations ?? [];
const anyHours = listed.some((station) => station.openingHours);
const visited = listed.filter((station) => station.key && visits?.[station.key] === 'visited').length;
const marking = markState(canMark, assignmentId);
return (
<DialogSection
className="flex-none"
label={t('tours.walk.stations')}
action={
stations && assignmentId !== null ? (
<SoftPill>{t('tours.walk.progress', { visited, total: stations.length })}</SoftPill>
// No count where the stations could not be read: it would contradict the day row's.
assignmentId !== null && listed.length > 0 ? (
<SoftPill>{t('tours.walk.progress', { visited, total: listed.length })}</SoftPill>
) : undefined
}
>
<div data-testid="walk-stations" className="flex flex-col gap-2">
<WalkStationsNotes
loading={stations === null}
any={(stations?.length ?? 0) > 0}
unread={stations?.length === 0 && tour.station_count !== 0}
any={listed.length > 0}
noStart={!!day && start === null}
hoursShown={!!arrivals && anyHours}
fromDay={assignmentId === null}
notSynced={marking === 'notSynced'}
/>
<ol className="m-0 flex list-none flex-col gap-1.5 p-0">
{(stations ?? []).map((station, index) => (
{listed.map((station, index) => (
<StationRow
key={station.id}
station={station}
@@ -279,7 +314,7 @@ export function WalkStationsCard({
status={station.key ? visits?.[station.key] : undefined}
arrival={arrivals?.[index] ?? null}
date={day?.date ?? null}
onMark={canCheck && station.key ? (next) => onMark(station.key ?? '', next) : null}
onMark={marking === 'ready' && station.key ? (next) => onMark(station.key ?? '', next) : null}
/>
))}
</ol>
@@ -0,0 +1,126 @@
// CW-03: the visits of a trip's city walks, read when the trip opens and when
// the connection changes, and kept current by its socket through the store.
import { act, renderHook } from '@testing-library/react';
import type { TourVisit } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { addListener, removeListener } from '../../../api/websocket';
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { setForcedOffline } from '../../../sync/networkMode';
import { useTourVisitsSync } from './useTourVisitsSync';
const visit = (assignment_id: number, station_key: string): TourVisit => ({
assignment_id,
station_key,
status: 'visited',
});
function later<T>() {
let settle: (value: T) => void = () => undefined;
let fail: (reason: unknown) => void = () => undefined;
const promise = new Promise<T>((resolve, reject) => {
settle = resolve;
fail = reject;
});
return { promise, settle, fail };
}
beforeEach(() => {
useTourVisitStore.setState({ tripId: null, byAssignment: {} });
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
act(() => setForcedOffline(false));
});
describe('useTourVisitsSync', () => {
it('reads nothing and listens to nothing while off, without a trip, or for a trip id that is no number', () => {
const list = vi.spyOn(tourVisitRepo, 'list');
renderHook(() => useTourVisitsSync(4, false));
renderHook(() => useTourVisitsSync(undefined, true));
renderHook(() => useTourVisitsSync('abc', true));
expect(list).not.toHaveBeenCalled();
expect(addListener).not.toHaveBeenCalled();
});
it("reads the trip's visits and listens to its socket, once", async () => {
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [visit(9, 'stop-01')] });
renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } }));
expect(list).toHaveBeenCalledExactlyOnceWith(4);
expect(addListener).toHaveBeenCalledOnce();
});
it('reads them again when the connection goes or comes back, without listening twice', async () => {
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(1));
act(() => setForcedOffline(true));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2));
act(() => setForcedOffline(false));
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3));
expect(addListener).toHaveBeenCalledOnce();
expect(removeListener).not.toHaveBeenCalled();
});
it('a read that fails forgets the visits, but not once the hook is gone', async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const answer = later<{ visits: TourVisit[] }>();
vi.spyOn(tourVisitRepo, 'list')
.mockRejectedValueOnce(new Error('No permission'))
.mockReturnValueOnce(answer.promise);
const first = renderHook(() => useTourVisitsSync(4, true));
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({}));
first.unmount();
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const second = renderHook(() => useTourVisitsSync(4, true));
second.unmount();
answer.fail(new Error('Gone'));
await expect(answer.promise).rejects.toThrow('Gone');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } });
});
it('stops listening when it goes, and listens to the next trip when the trip changes', () => {
vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
const { rerender, unmount } = renderHook(({ trip }) => useTourVisitsSync(trip, true), {
initialProps: { trip: 4 },
});
const [[firstListener]] = vi.mocked(addListener).mock.calls;
rerender({ trip: 5 });
expect(removeListener).toHaveBeenCalledWith(firstListener);
expect(addListener).toHaveBeenCalledTimes(2);
const secondListener = vi.mocked(addListener).mock.calls[1][0];
unmount();
expect(removeListener).toHaveBeenLastCalledWith(secondListener);
});
it('a read for the trip left before answers too late to show', async () => {
const left = later<{ visits: TourVisit[] }>();
vi.spyOn(tourVisitRepo, 'list')
.mockReturnValueOnce(left.promise)
.mockResolvedValueOnce({ visits: [visit(30, 'stop-02')] });
const { rerender } = renderHook(({ trip }) => useTourVisitsSync(trip, true), { initialProps: { trip: 4 } });
rerender({ trip: 5 });
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 30: { 'stop-02': 'visited' } }));
left.settle({ visits: [visit(9, 'stop-01')] });
await left.promise;
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: { 30: { 'stop-02': 'visited' } } });
});
});
@@ -1,39 +1,36 @@
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';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTourVisitStore, watchTourVisits } from '../../../store/tourVisitStore';
/**
* 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.
* CW-03: the visits of a trip's city walks, read when the trip opens and again
* when the connection comes and goes, and kept current by its socket, so a
* station another traveller checks off shows on every day plan at once. Once a
* reconnect has sent the waiting marks, the sync reads them once more
* (reloadTourVisits). Offline they read from this device, with the marks made
* here still waiting to sync.
*/
export function useTourVisitsSync(tripId: number | string | undefined, enabled: boolean) {
const { offline } = useNetworkMode();
const id = enabled && tripId !== undefined && !Number.isNaN(Number(tripId)) ? tripId : null;
useEffect(() => {
if (!enabled || tripId === undefined) return;
if (id === null) return;
let current = true;
useTourVisitStore
.getState()
.load(tripId)
.load(id)
.catch(() => {
// Offline or refused, the day plan shows no progress rather than another trip's.
if (current) useTourVisitStore.getState().reset(tripId);
// Refused, or no copy on this device: the day plan shows no progress rather than another trip's.
if (current) useTourVisitStore.getState().reset(id);
});
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]);
}, [id, offline]);
useEffect(() => {
if (id === null) return;
return watchTourVisits(id);
}, [id]);
}
@@ -0,0 +1,44 @@
// CW-03: a city walk's progress on one of its days, read alike by the day rows of the desktop and the phone.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourVisitStore } from '../../../store/tourVisitStore';
import { useDayVisits, useWalkProgress, walkProgressOf } from './walkProgress';
const walk = { tour_type: 'walk', station_count: 5 } as const;
beforeEach(() => useTourVisitStore.setState({ tripId: null, byAssignment: {} }));
describe('walkProgressOf', () => {
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
const visits = { 'stop-01': 'visited' } as const;
expect(walkProgressOf({ tour_type: 'hike', station_count: 5 }, visits)).toBeNull();
expect(walkProgressOf({ ...walk, station_count: 0 }, visits)).toBeNull();
expect(walkProgressOf({ ...walk, station_count: null }, visits)).toBeNull();
expect(walkProgressOf({ tour_type: 'walk' }, visits)).toBeNull();
expect(walkProgressOf(walk, undefined)).toBeNull();
expect(walkProgressOf(walk, {})).toBeNull();
});
it('counts the visited stops of all of them, a skip as marked but not visited', () => {
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'skipped' } as const;
expect(walkProgressOf(walk, visits)).toEqual({ visited: 2, total: 5 });
expect(walkProgressOf(walk, { 'stop-03': 'skipped' })).toEqual({ visited: 0, total: 5 });
});
it('never counts more visited stops than the walk has', () => {
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'visited' } as const;
expect(walkProgressOf({ ...walk, station_count: 2 }, visits)).toEqual({ visited: 2, total: 2 });
});
});
describe('useWalkProgress', () => {
it("reads the day's visits from the store, and nothing without a day", () => {
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' }, 10: { 'stop-02': 'skipped' } } });
expect(renderHook(() => useWalkProgress(walk, 9)).result.current).toEqual({ visited: 1, total: 5 });
expect(renderHook(() => useWalkProgress(walk, 10)).result.current).toEqual({ visited: 0, total: 5 });
expect(renderHook(() => useWalkProgress(walk, 11)).result.current).toBeNull();
expect(renderHook(() => useWalkProgress(walk, undefined)).result.current).toBeNull();
expect(renderHook(() => useDayVisits(null)).result.current).toBeUndefined();
expect(renderHook(() => useDayVisits(9)).result.current).toEqual({ 'stop-01': 'visited' });
});
});
@@ -0,0 +1,32 @@
import type { TourListItem } from '@trek/shared';
import { useTourVisitStore, type VisitsByStation } from '../../../store/tourVisitStore';
/** The day's status of each station of a walk, by its key; undefined before any is marked or without a day. */
export function useDayVisits(assignmentId: number | null | undefined): VisitsByStation | undefined {
return useTourVisitStore((state) => (assignmentId == null ? undefined : state.byAssignment[assignmentId]));
}
/**
* CW-03: how many of a walk's stations were visited on one day, out of all of
* them, or null where there is nothing to say yet: not a walk, no stations, or
* none marked on that day. A skip counts as marked but not as visited.
*/
export function walkProgressOf(
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
visits: VisitsByStation | undefined
): { visited: number; total: number } | null {
const total = tour.station_count ?? 0;
if (tour.tour_type !== 'walk' || total === 0 || !visits) return null;
const statuses = Object.values(visits);
if (statuses.length === 0) return null;
const visited = statuses.filter((status) => status === 'visited').length;
return { visited: Math.min(visited, total), total };
}
/** A walk's progress on the day its row stands for, shared by the day rows of the desktop and the phone. */
export function useWalkProgress(
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
assignmentId: number | null | undefined
): { visited: number; total: number } | null {
return walkProgressOf(tour, useDayVisits(assignmentId));
}
+36
View File
@@ -0,0 +1,36 @@
import type { TourVisit } from '@trek/shared';
import { offlineDb, type CachedTourStationVisit } from './offlineDb';
function rowOf(tripId: number, visit: TourVisit): CachedTourStationVisit {
return { assignment_id: visit.assignment_id, station_key: visit.station_key, status: visit.status, trip_id: tripId };
}
/**
* CW-03: a trip's walk visits as the server listed them. The rows it no longer
* lists go, so a stop taken back on another device does not come back offline.
*/
export async function replaceTripVisits(tripId: number, visits: readonly TourVisit[]): Promise<void> {
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
await offlineDb.tourStationVisits.where('trip_id').equals(tripId).delete();
await offlineDb.tourStationVisits.bulkPut(visits.map((visit) => rowOf(tripId, visit)));
});
}
/** One day's visits of a walk as the server answered a mark of it; the other days stay as they are. */
export async function replaceAssignmentVisits(
tripId: number,
assignmentId: number,
visits: readonly TourVisit[]
): Promise<void> {
const own = visits.filter((visit) => visit.assignment_id === assignmentId);
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
await offlineDb.tourStationVisits.where('assignment_id').equals(assignmentId).delete();
await offlineDb.tourStationVisits.bulkPut(own.map((visit) => rowOf(tripId, visit)));
});
}
/** The trip's walk visits as last cached, without the trip they were filed under. */
export async function cachedTripVisits(tripId: number): Promise<TourVisit[]> {
const rows = await offlineDb.tourStationVisits.where('trip_id').equals(tripId).toArray();
return rows.map((row) => ({ assignment_id: row.assignment_id, station_key: row.station_key, status: row.status }));
}
+19 -1
View File
@@ -1,4 +1,4 @@
import type { Ferrata, RoadtripPreferences, TourListItem, TourObstacleCheck, TourProvenance, TourWaypoint } from '@trek/shared';
import type { Ferrata, RoadtripPreferences, TourListItem, TourObstacleCheck, TourProvenance, TourVisit, TourWaypoint } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -46,6 +46,16 @@ interface CachedFerrataTile {
ferratas: Ferrata[];
}
/**
* CW-03: a station of a city walk visited or skipped on one of its days, as the
* server last said, with the trip it belongs to so it drops with the trip. A
* mark made offline is not written here: it waits in the queue, and every read
* lays it over these rows (see repo/tourVisitRepo).
*/
export interface CachedTourStationVisit extends TourVisit {
trip_id: number;
}
// ── Queue + sync types ────────────────────────────────────────────────────────
// 'conflict' is terminal-until-resolved: the server rejected the replay because
@@ -253,6 +263,7 @@ export class TrekOfflineDb extends Dexie {
tours!: Table<CachedTour, number>;
budgetSettlements!: Table<CachedBudgetSettlement, number>;
ferrataTiles!: Table<CachedFerrataTile, [number, string]>;
tourStationVisits!: Table<CachedTourStationVisit, [number, string]>;
constructor(name: string = ANON_DB_NAME) {
super(name);
@@ -331,6 +342,11 @@ export class TrekOfflineDb extends Dexie {
// v11: the via ferrata layer's loaded tiles, per trip, so the lines draw at
// once and offline; `tripId` so they drop with the trip.
this.version(11).stores({ ferrataTiles: '[tripId+tile], tripId, fetchedAt' });
// v12: the visits of the city walks, one row per station and day, so a
// walk's stops read as checked off offline; `trip_id` so they drop with the
// trip, `assignment_id` so one day's visits are replaced at once.
this.version(12).stores({ tourStationVisits: '[assignment_id+station_key], trip_id, assignment_id' });
}
}
@@ -620,6 +636,7 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.tours,
offlineDb.budgetSettlements,
offlineDb.ferrataTiles,
offlineDb.tourStationVisits,
],
async () => {
await offlineDb.roadtripPreferences.delete(tripId);
@@ -633,6 +650,7 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
await offlineDb.accommodations.where('trip_id').equals(tripId).delete();
await offlineDb.tours.where('trip_id').equals(tripId).delete();
await offlineDb.tourStationVisits.where('trip_id').equals(tripId).delete();
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
@@ -3,6 +3,7 @@ import en from '@trek/shared/i18n/en';
import { describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildPlace } from '../../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useTourVisitStore } from '../../../../store/tourVisitStore';
import { PlaceRow } from './MPlanTimelineRows';
const place = buildPlace({ id: 42, name: 'Ridge', route_geometry: '[[48,11],[49,12]]' });
@@ -56,4 +57,25 @@ describe('RC-01 mobile PLAN Tour rows', () => {
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
});
it("CW-03: a city walk's row shows the stops visited on its own day, a tap not opening the row", () => {
useTourVisitStore.setState({
tripId: '1',
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'visited' }, 10: { 'stop-03': 'visited' } },
});
const onOpen = vi.fn();
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
render(<PlaceRow {...props} tour={walk} onOpen={onOpen} />);
const progress = screen.getByTestId('plan-walk-progress');
expect(progress).toHaveTextContent('2/5');
fireEvent.click(progress);
expect(screen.getByRole('note')).toHaveTextContent('2 of 5 visited');
expect(onOpen).not.toHaveBeenCalled();
});
it('CW-03: a hike on a day with marks shows no progress', () => {
useTourVisitStore.setState({ tripId: '1', byAssignment: { 9: { 'stop-01': 'visited' } } });
render(<PlaceRow {...props} tour={tour} />);
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
});
});
@@ -200,7 +200,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
</span>
)}
</div>
{tour && <div className="mt-[2px]"><TourDayRowFacts tour={tour} mobile /></div>}
{tour && <div className="mt-[2px]"><TourDayRowFacts tour={tour} assignmentId={assignment.id} mobile /></div>}
{(time || sub) && (
<div className="mt-[2px] flex min-w-0 items-center gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
@@ -0,0 +1,135 @@
// CW-03: checking a city walk's stops off without a connection. The queued mark
// is the only copy of it, and every read lays it over the visits kept here.
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueuedMutation } from '../db/offlineDb';
import { clearAll, offlineDb } from '../db/offlineDb';
import { mutationQueue } from '../sync/mutationQueue';
import { tourVisitRepo } from './tourVisitRepo';
function setOnline(value: boolean): void {
Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true });
}
let rows = 0;
function queued(overrides: Partial<QueuedMutation>): QueuedMutation {
rows += 1;
return {
id: `row-${rows}`,
tripId: 4,
method: 'PUT',
url: '/trips/4/tour-visits/9/stop-01',
body: { status: 'visited' },
createdAt: 1,
status: 'pending',
attempts: 0,
lastError: null,
resource: 'tourStationVisits',
entityId: 9,
...overrides,
};
}
const keys = (visits: ReadonlyArray<{ assignment_id: number; station_key: string; status: string }>) =>
visits.map((visit) => `${visit.assignment_id}:${visit.station_key}:${visit.status}`).sort();
beforeEach(async () => {
await clearAll();
mutationQueue._resetFlushing();
setOnline(false);
await offlineDb.tourStationVisits.bulkPut([
{ assignment_id: 9, station_key: 'stop-02', status: 'visited', trip_id: 4 },
{ assignment_id: 10, station_key: 'stop-01', status: 'skipped', trip_id: 4 },
{ assignment_id: 30, station_key: 'stop-01', status: 'visited', trip_id: 5 },
]);
});
afterEach(() => {
vi.restoreAllMocks();
setOnline(true);
});
describe('tourVisitRepo offline', () => {
it("FE-REPO-VISIT-OFF-001: a mark joins the queue as the server's own PUT and the day reads with it at once", async () => {
const { visits } = await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' });
expect(keys(visits)).toEqual(['9:stop-01:visited', '9:stop-02:visited']);
const [row] = await offlineDb.mutationQueue.toArray();
expect(row).toMatchObject({
tripId: 4,
method: 'PUT',
url: '/trips/4/tour-visits/9/stop-01',
body: { status: 'visited' },
resource: 'tourStationVisits',
entityId: 9,
status: 'pending',
});
// The kept rows stay as the server last said them.
expect(await offlineDb.tourStationVisits.get([9, 'stop-01'])).toBeUndefined();
});
it('FE-REPO-VISIT-OFF-002: the trip reads with every waiting mark over the kept visits, a take back hiding one', async () => {
await tourVisitRepo.update(4, 9, 'stop-01', { status: 'skipped' });
await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' });
await tourVisitRepo.update(4, 9, 'stop-02', { status: 'planned' });
const { visits } = await tourVisitRepo.list(4);
expect(keys(visits)).toEqual(['10:stop-01:skipped', '9:stop-01:visited']);
});
it('FE-REPO-VISIT-OFF-003: a mark the server refused is not laid over, nor a row of another kind', async () => {
await offlineDb.mutationQueue.bulkPut([
queued({ id: 'failed', status: 'failed', body: { status: 'planned' }, url: '/trips/4/tour-visits/9/stop-02' }),
queued({ id: 'conflict', status: 'conflict', createdAt: 2 }),
queued({ id: 'place', resource: 'places', entityId: 9, createdAt: 3 }),
queued({ id: 'no-day', entityId: undefined, createdAt: 4 }),
queued({ id: 'bad-key', url: '/trips/4/tour-visits/9/a%2Fb', createdAt: 5 }),
queued({ id: 'bad-body', body: { status: 'done' }, createdAt: 6 }),
queued({ id: 'other-trip', tripId: 5, entityId: 30, url: '/trips/5/tour-visits/30/stop-09', createdAt: 7 }),
]);
const { visits } = await tourVisitRepo.list(4);
expect(keys(visits)).toEqual(['10:stop-01:skipped', '9:stop-02:visited']);
});
it('FE-REPO-VISIT-OFF-004: online behind an older mark of the same day, a mark waits behind it and asks for a send', async () => {
setOnline(true);
await offlineDb.mutationQueue.put(queued({ id: 'older', status: 'failed' }));
const sendSoon = vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
const { visits } = await tourVisitRepo.update(4, 9, 'stop-03', { status: 'skipped' });
expect(keys(visits)).toEqual(['9:stop-02:visited', '9:stop-03:skipped']);
expect(sendSoon).toHaveBeenCalledOnce();
const queue = await offlineDb.mutationQueue.orderBy('createdAt').toArray();
expect(queue.map((row) => row.url)).toEqual(['/trips/4/tour-visits/9/stop-01', '/trips/4/tour-visits/9/stop-03']);
});
it('FE-REPO-VISIT-OFF-005: a day put on the plan offline cannot be marked before it synced', async () => {
await expect(tourVisitRepo.update(4, -1700000000000, 'stop-01', { status: 'visited' })).rejects.toThrow(
'This day has not synced yet.'
);
expect(await offlineDb.mutationQueue.count()).toBe(0);
});
it("FE-REPO-VISIT-OFF-006: another traveller's marks are kept, with this device's waiting mark still on top", async () => {
await tourVisitRepo.update(4, 9, 'stop-01', { status: 'skipped' });
const { visits } = await tourVisitRepo.received(4, 9, [
{ assignment_id: 9, station_key: 'stop-01', status: 'visited' },
{ assignment_id: 9, station_key: 'stop-04', status: 'visited' },
]);
expect(keys(visits)).toEqual(['9:stop-01:skipped', '9:stop-04:visited']);
await vi.waitFor(async () =>
expect(keys(await offlineDb.tourStationVisits.where('assignment_id').equals(9).toArray())).toEqual([
'9:stop-01:visited',
'9:stop-04:visited',
])
);
expect(await offlineDb.tourStationVisits.get([10, 'stop-01'])).toMatchObject({ status: 'skipped' });
});
});
+108
View File
@@ -0,0 +1,108 @@
// CW-03: a city walk's visits online. The server answers, and the answer is kept on this device.
import 'fake-indexeddb/auto';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { clearAll, offlineDb } from '../db/offlineDb';
import { tourVisitRepo } from './tourVisitRepo';
function setOnline(value: boolean): void {
Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true });
}
const cachedKeys = async () =>
(await offlineDb.tourStationVisits.toArray()).map((row) => `${row.trip_id}:${row.assignment_id}:${row.station_key}`);
beforeEach(async () => {
await clearAll();
setOnline(true);
});
afterEach(() => {
vi.restoreAllMocks();
setOnline(true);
});
describe('tourVisitRepo online', () => {
it("FE-REPO-VISIT-001: list reads the trip's visits and keeps the whole trip on this device", async () => {
await offlineDb.tourStationVisits.put({ assignment_id: 9, station_key: 'gone-01', status: 'visited', trip_id: 4 });
const visits = [
{ assignment_id: 9, station_key: 'stop-01', status: 'visited' as const },
{ assignment_id: 10, station_key: 'stop-01', status: 'skipped' as const },
];
server.use(http.get('/api/trips/4/tour-visits', () => HttpResponse.json({ visits })));
await expect(tourVisitRepo.list(4)).resolves.toEqual({ visits });
await vi.waitFor(async () => expect((await cachedKeys()).sort()).toEqual(['4:10:stop-01', '4:9:stop-01']));
});
it("FE-REPO-VISIT-002: a mark goes straight to the server, and the day's answer replaces only that day here", async () => {
await offlineDb.tourStationVisits.bulkPut([
{ assignment_id: 9, station_key: 'stop-02', status: 'visited', trip_id: 4 },
{ assignment_id: 10, station_key: 'stop-01', status: 'skipped', trip_id: 4 },
]);
const bodies: unknown[] = [];
const answer = { visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' as const }] };
server.use(
http.put('/api/trips/4/tour-visits/9/stop-01', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(answer);
})
);
await expect(tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' })).resolves.toEqual(answer);
expect(bodies).toEqual([{ status: 'visited' }]);
expect(await offlineDb.mutationQueue.count()).toBe(0);
await vi.waitFor(async () => expect((await cachedKeys()).sort()).toEqual(['4:10:stop-01', '4:9:stop-01']));
});
it('FE-REPO-VISIT-003: a refused mark throws and keeps what this device had', async () => {
await offlineDb.tourStationVisits.put({ assignment_id: 9, station_key: 'stop-01', status: 'visited', trip_id: 4 });
server.use(
http.put('/api/trips/4/tour-visits/9/stop-01', () =>
HttpResponse.json({ error: 'Station not found' }, { status: 404 })
)
);
await expect(tourVisitRepo.update(4, 9, 'stop-01', { status: 'planned' })).rejects.toMatchObject({
response: { status: 404 },
});
expect(await cachedKeys()).toEqual(['4:9:stop-01']);
});
it('FE-REPO-VISIT-004: a lost connection reads the copy on this device', async () => {
await offlineDb.tourStationVisits.put({ assignment_id: 9, station_key: 'stop-01', status: 'skipped', trip_id: 4 });
server.use(http.get('/api/trips/4/tour-visits', () => HttpResponse.error()));
await expect(tourVisitRepo.list(4)).resolves.toEqual({
visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'skipped' }],
});
});
it('FE-REPO-VISIT-005: an answer from the server still shows a mark waiting in the queue', async () => {
await offlineDb.mutationQueue.put({
id: 'mark-1',
tripId: 4,
method: 'PUT',
url: '/trips/4/tour-visits/9/stop-02',
body: { status: 'visited' },
createdAt: 1,
status: 'syncing',
attempts: 0,
lastError: null,
resource: 'tourStationVisits',
entityId: 9,
});
server.use(
http.get('/api/trips/4/tour-visits', () =>
HttpResponse.json({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] })
)
);
const { visits } = await tourVisitRepo.list('4');
expect(visits.map((visit) => visit.station_key).sort()).toEqual(['stop-01', 'stop-02']);
});
});
+104 -10
View File
@@ -1,24 +1,118 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import { tourVisitsApi } from '../api/tourVisits';
import { requireOnline } from './onlineOnly';
import {
stationKeySchema,
tourVisitUpdateRequestSchema,
type TourVisit,
type TourVisitListResponse,
type TourVisitUpdateRequest,
} from '@trek/shared';
import { tourVisitPath, tourVisitsApi } from '../api/tourVisits';
import { cachedTripVisits, replaceAssignmentVisits, replaceTripVisits } from '../db/cacheTourVisits';
import { offlineDb, type QueuedMutation } from '../db/offlineDb';
import { generateUUID, mutationQueue } from '../sync/mutationQueue';
import { cacheAfterWrite } from './dayRepo';
import { onlineThenCache } from './withOfflineFallback';
/** The Dexie table of the cached visits and the queue's resource of a mark, one name as everywhere else. */
const RESOURCE = 'tourStationVisits';
interface QueuedMark {
assignmentId: number;
stationKey: string;
status: TourVisitUpdateRequest['status'];
}
/**
* 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.
* A mark still waiting in the queue, or null for any other row. One that failed
* or waits as a conflict is not laid over: the server did not take it, and the
* day shows what the server has until the user tries it again. The body names
* only the status, so the station comes from the path it is sent to.
*/
function queuedMark(m: QueuedMutation): QueuedMark | null {
if (m.resource !== RESOURCE || m.entityId === undefined) return null;
if (m.status !== 'pending' && m.status !== 'syncing') return null;
const key = stationKeySchema.safeParse(m.url.slice(m.url.lastIndexOf('/') + 1));
const body = tourVisitUpdateRequestSchema.safeParse(m.body);
if (!key.success || !body.success) return null;
return { assignmentId: m.entityId, stationKey: key.data, status: body.data.status };
}
/** The visits with the trip's queued marks laid over them in the order they were made. */
async function withQueuedMarks(tripId: number, base: readonly TourVisit[]): Promise<TourVisit[]> {
const queue = await offlineDb.mutationQueue.where('tripId').equals(tripId).sortBy('createdAt');
const visits = new Map(base.map((visit) => [`${visit.assignment_id}:${visit.station_key}`, visit]));
for (const mark of queue.map(queuedMark)) {
if (!mark) continue;
const id = `${mark.assignmentId}:${mark.stationKey}`;
if (mark.status === 'planned') visits.delete(id);
else visits.set(id, { assignment_id: mark.assignmentId, station_key: mark.stationKey, status: mark.status });
}
return [...visits.values()];
}
function ofDay(visits: readonly TourVisit[], assignmentId: number): TourVisitListResponse {
return { visits: visits.filter((visit) => visit.assignment_id === assignmentId) };
}
/**
* CW-03 through the offline core. Dexie holds the visits as the server last
* said them, and a mark made offline, or online behind an older one still
* queued, waits in the mutation queue as the only copy of it; every read lays
* those marks over the cached rows, so a read from the server cannot hide one.
* Marks carry no version: the last one to reach the server wins.
*/
export const tourVisitRepo = {
async list(tripId: number | string): Promise<TourVisitListResponse> {
requireOnline();
return await tourVisitsApi.list(tripId);
const id = Number(tripId);
return onlineThenCache(
async () => {
const { visits } = await tourVisitsApi.list(tripId);
cacheAfterWrite(replaceTripVisits(id, visits), 'reading the visits of the city walks');
return { visits: await withQueuedMarks(id, visits) };
},
async () => ({ visits: await withQueuedMarks(id, await cachedTripVisits(id)) })
);
},
/**
* Marks one station for one day. Offline, and online while an older mark of
* that day still waits, it joins the queue and the day reads with it at once.
* A day put on the plan offline has no id the server knows yet, so it cannot
* be marked before it synced.
*/
async update(
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> {
requireOnline();
return await tourVisitsApi.update(tripId, assignmentId, stationKey, body);
if (assignmentId < 0) throw new Error('This day has not synced yet.');
const id = Number(tripId);
if (await mutationQueue.mustQueue(RESOURCE, assignmentId)) {
await mutationQueue.enqueue({
id: generateUUID(),
tripId: id,
method: 'PUT',
url: tourVisitPath(tripId, assignmentId, stationKey),
body,
resource: RESOURCE,
entityId: assignmentId,
});
mutationQueue.sendSoon();
return ofDay(await withQueuedMarks(id, await cachedTripVisits(id)), assignmentId);
}
const answer = await tourVisitsApi.update(tripId, assignmentId, stationKey, body);
cacheAfterWrite(replaceAssignmentVisits(id, assignmentId, answer.visits), 'checking off a stop of a city walk');
return answer;
},
/** A day's visits another traveller changed, as the trip's socket told them, with this device's queued marks on top. */
async received(
tripId: number | string,
assignmentId: number,
visits: readonly TourVisit[]
): Promise<TourVisitListResponse> {
const id = Number(tripId);
cacheAfterWrite(replaceAssignmentVisits(id, assignmentId, visits), 'a city walk was checked off elsewhere');
return ofDay(await withQueuedMarks(id, visits), assignmentId);
},
};
+216
View File
@@ -0,0 +1,216 @@
// CW-03: the visits store of the city walks: read per trip, marked at once on
// screen, kept current by the trip's socket.
import type { TourVisit } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { addListener, removeListener } from '../api/websocket';
import { tourVisitRepo } from '../repo/tourVisitRepo';
import type { WebSocketEvent } from '../types';
import { useAddonStore } from './addonStore';
import { reloadTourVisits, useTourVisitStore, watchTourVisits } from './tourVisitStore';
const visit = (assignment_id: number, station_key: string, status: TourVisit['status']): TourVisit => ({
assignment_id,
station_key,
status,
});
/** A promise and the function that settles it, for an answer that comes later. */
function later<T>() {
let settle: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, settle };
}
beforeEach(() => {
useTourVisitStore.setState({ tripId: null, byAssignment: {} });
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
});
afterEach(() => vi.restoreAllMocks());
describe('tourVisitStore', () => {
it('load groups the visits by day and stop, and reset forgets them', async () => {
vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({
visits: [visit(9, 'stop-01', 'visited'), visit(9, 'stop-02', 'skipped'), visit(10, 'stop-01', 'visited')],
});
await useTourVisitStore.getState().load(4);
expect(useTourVisitStore.getState()).toMatchObject({
tripId: '4',
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' }, 10: { 'stop-01': 'visited' } },
});
useTourVisitStore.getState().reset(5);
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: {} });
});
it("another trip's visits go at once, and its load answering late changes nothing", async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const first = later<{ visits: TourVisit[] }>();
vi.spyOn(tourVisitRepo, 'list')
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ visits: [visit(30, 'stop-01', 'skipped')] });
const left = useTourVisitStore.getState().load(5);
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: {} });
await useTourVisitStore.getState().load(6);
first.settle({ visits: [visit(20, 'stop-01', 'visited')] });
await left;
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '6', byAssignment: { 30: { 'stop-01': 'skipped' } } });
});
it("mark shows the stop at once and then takes the day's answer; a take back drops the stop", async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-02': 'visited' } } });
const answer = later<{ visits: TourVisit[] }>();
const update = vi.spyOn(tourVisitRepo, 'update').mockReturnValueOnce(answer.promise);
const marking = useTourVisitStore.getState().mark(4, 9, 'stop-01', 'skipped');
expect(useTourVisitStore.getState().byAssignment[9]).toEqual({ 'stop-02': 'visited', 'stop-01': 'skipped' });
answer.settle({ visits: [visit(9, 'stop-01', 'skipped'), visit(9, 'stop-03', 'visited')] });
await marking;
expect(update).toHaveBeenCalledWith(4, 9, 'stop-01', { status: 'skipped' });
expect(useTourVisitStore.getState().byAssignment[9]).toEqual({ 'stop-01': 'skipped', 'stop-03': 'visited' });
update.mockResolvedValueOnce({ visits: [] });
const takingBack = useTourVisitStore.getState().mark(4, 9, 'stop-01', 'planned');
expect(useTourVisitStore.getState().byAssignment[9]).toEqual({ 'stop-03': 'visited' });
await takingBack;
});
it('a refused mark puts the day back as it was, or empty, and throws', async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-02': 'visited' } } });
const refused = new Error('No permission');
vi.spyOn(tourVisitRepo, 'update').mockRejectedValue(refused);
await expect(useTourVisitStore.getState().mark(4, 9, 'stop-01', 'visited')).rejects.toBe(refused);
await expect(useTourVisitStore.getState().mark(4, 10, 'stop-01', 'visited')).rejects.toBe(refused);
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-02': 'visited' }, 10: {} });
});
it('applyRemote replaces only that day', () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-02': 'visited' }, 10: { a: 'skipped' } } });
useTourVisitStore.getState().applyRemote(9, [visit(9, 'stop-05', 'visited')]);
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-05': 'visited' }, 10: { a: 'skipped' } });
});
it("receive keeps this device's waiting marks on top, and the server's list when they cannot be read", async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: {} });
const received = vi
.spyOn(tourVisitRepo, 'received')
.mockResolvedValueOnce({ visits: [visit(9, 'stop-01', 'skipped')] })
.mockRejectedValueOnce(new Error('Queue not readable'));
await useTourVisitStore.getState().receive(4, 9, [visit(9, 'stop-01', 'visited')]);
expect(received).toHaveBeenCalledWith(4, 9, [visit(9, 'stop-01', 'visited')]);
expect(useTourVisitStore.getState().byAssignment[9]).toEqual({ 'stop-01': 'skipped' });
await useTourVisitStore.getState().receive(4, 10, [visit(10, 'stop-02', 'visited')]);
expect(useTourVisitStore.getState().byAssignment[10]).toEqual({ 'stop-02': 'visited' });
});
it('receive for a trip left meanwhile changes nothing', async () => {
useTourVisitStore.setState({ tripId: '5', byAssignment: {} });
vi.spyOn(tourVisitRepo, 'received').mockResolvedValue({ visits: [visit(9, 'stop-01', 'visited')] });
await useTourVisitStore.getState().receive(4, 9, [visit(9, 'stop-01', 'visited')]);
expect(useTourVisitStore.getState().byAssignment).toEqual({});
});
});
describe('reloadTourVisits', () => {
const toursOn = (enabled: boolean) =>
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: 'map', enabled }] });
beforeEach(() => toursOn(true));
afterEach(() => useAddonStore.setState({ addons: [] }));
it("reads the open trip's visits again, so a mark the server no longer has leaves", async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'gone-01': 'visited', 'stop-02': 'skipped' } } });
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [visit(9, 'stop-02', 'skipped')] });
reloadTourVisits(4);
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-02': 'skipped' } }));
expect(list).toHaveBeenCalledExactlyOnceWith(4);
});
it('leaves the visits of another trip alone, and every trip while Tours is off', () => {
const list = vi.spyOn(tourVisitRepo, 'list');
useTourVisitStore.setState({ tripId: '5', byAssignment: { 9: { 'stop-01': 'visited' } } });
reloadTourVisits(4);
toursOn(false);
reloadTourVisits(5);
expect(list).not.toHaveBeenCalled();
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: { 9: { 'stop-01': 'visited' } } });
});
it('a read that fails shows no progress, unless another trip was opened meanwhile', async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
const refused = later<{ visits: TourVisit[] }>();
const list = vi.spyOn(tourVisitRepo, 'list').mockRejectedValueOnce(new Error('No permission'));
reloadTourVisits(4);
await vi.waitFor(() => expect(useTourVisitStore.getState()).toMatchObject({ tripId: '4', byAssignment: {} }));
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
list
.mockImplementationOnce(() => refused.promise.then(() => Promise.reject(new Error('Gone'))))
.mockResolvedValueOnce({ visits: [visit(30, 'stop-01', 'skipped')] });
reloadTourVisits(4);
await useTourVisitStore.getState().load(6);
refused.settle({ visits: [] });
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '6', byAssignment: { 30: { 'stop-01': 'skipped' } } });
});
});
describe('watchTourVisits', () => {
function listener(): (event: WebSocketEvent) => void {
const calls = vi.mocked(addListener).mock.calls;
return calls[calls.length - 1][0];
}
it("applies the trip's visits that read as visits, and ignores everything else", async () => {
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-02': 'visited' }, 11: { a: 'skipped' } } });
const received = vi
.spyOn(tourVisitRepo, 'received')
.mockImplementation(async (_trip, _day, visits) => ({ visits: [...visits] }));
const stop = watchTourVisits(4);
const onEvent = listener();
onEvent({ type: 'tours:visits', tripId: 5, assignmentId: 9, visits: [] });
onEvent({ type: 'tours:changed', tripId: 4, assignmentId: 9, visits: [] });
onEvent({ type: 'tours:visits', tripId: 4, assignmentId: 9, visits: 'none' });
onEvent({ type: 'tours:visits', tripId: 4, assignmentId: '9', visits: [] });
onEvent({ type: 'tours:visits', tripId: 4, assignmentId: -9, visits: [] });
onEvent({ type: 'tours:visits', tripId: 4, assignmentId: 9.5, visits: [] });
onEvent({ type: 'tours:visits', tripId: 4, visits: [] });
expect(received).not.toHaveBeenCalled();
onEvent({
type: 'tours:visits',
tripId: '4',
assignmentId: 9,
visits: [visit(9, 'stop-01', 'visited'), { assignment_id: 9, station_key: 'x', status: 'visited' }, null],
});
await vi.waitFor(() =>
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' }, 11: { a: 'skipped' } })
);
expect(received).toHaveBeenCalledWith(4, 9, [visit(9, 'stop-01', 'visited')]);
stop();
expect(removeListener).toHaveBeenCalledWith(onEvent);
});
});
+68 -7
View File
@@ -1,12 +1,15 @@
import type { TourVisit, TourVisitStatus } from '@trek/shared';
import { tourVisitSchema, type TourVisit, type TourVisitStatus } from '@trek/shared';
import { create } from 'zustand';
import { addListener, removeListener } from '../api/websocket';
import { tourVisitRepo } from '../repo/tourVisitRepo';
import type { WebSocketEvent } from '../types';
import { useAddonStore } from './addonStore';
/** A station's status on one day of its walk; a station not listed is planned. */
type VisitsByStation = Readonly<Record<string, TourVisitStatus>>;
export type VisitsByStation = Readonly<Record<string, TourVisitStatus>>;
interface TourVisitState {
/** The trip the visits were read for. */
/** The trip the visits are read for. */
tripId: string | null;
/** Per day assignment, the stations visited or skipped. */
byAssignment: Readonly<Record<number, VisitsByStation>>;
@@ -20,7 +23,9 @@ interface TourVisitState {
stationKey: string,
status: TourVisitStatus | 'planned'
) => Promise<void>;
/** Another traveller marked a station: the day's visits as the server has them now. */
/** Another traveller marked a station: the day's visits as the server has them now, this device's waiting marks on top. */
receive: (tripId: number | string, assignmentId: number, visits: readonly TourVisit[]) => Promise<void>;
/** The day's visits as they are now. */
applyRemote: (assignmentId: number, visits: readonly TourVisit[]) => void;
}
@@ -37,17 +42,23 @@ function withStatus(day: VisitsByStation | undefined, key: string, status: TourV
/**
* 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.
* the walk's stations. Read once a trip opens and again when the connection
* comes back, kept current by the trip's socket (watchTourVisits), and written
* one station at a time, offline through the queue.
*/
export const useTourVisitStore = create<TourVisitState>((set, get) => ({
tripId: null,
byAssignment: {},
load: async (tripId) => {
const key = String(tripId);
// Another trip's visits never show under this one, not even while it loads.
if (get().tripId !== key) set({ tripId: key, byAssignment: {} });
const { visits } = await tourVisitRepo.list(tripId);
// A trip left meanwhile answers too late to count.
if (get().tripId !== key) return;
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 });
set({ byAssignment: grouped });
},
reset: (tripId) => set({ tripId: String(tripId), byAssignment: {} }),
mark: async (tripId, assignmentId, stationKey, status) => {
@@ -63,6 +74,56 @@ export const useTourVisitStore = create<TourVisitState>((set, get) => ({
throw failure;
}
},
receive: async (tripId, assignmentId, visits) => {
const day = await tourVisitRepo.received(tripId, assignmentId, visits).catch(() => {
// The waiting marks could not be read: the server's list still beats an old one.
return { visits };
});
if (get().tripId === String(tripId)) get().applyRemote(assignmentId, day.visits);
},
applyRemote: (assignmentId, visits) =>
set((state) => ({ byAssignment: { ...state.byAssignment, [assignmentId]: byStation(visits) } })),
}));
/**
* CW-03: the open trip's visits read again once the queue has replayed after
* a reconnect, so a mark made offline that the server dropped or refused does
* not stay on the day plan. Another trip's visits are left alone, and so is
* every trip while the Tours addon is off. Visits that cannot be read show no
* progress rather than an old one.
*/
export function reloadTourVisits(tripId: number | string): void {
const key = String(tripId);
if (useTourVisitStore.getState().tripId !== key || !useAddonStore.getState().isEnabled('tours')) return;
useTourVisitStore
.getState()
.load(tripId)
.catch(() => {
if (useTourVisitStore.getState().tripId === key) useTourVisitStore.getState().reset(tripId);
});
}
function isDayId(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
/**
* Keeps the store current with the trip's socket, so a station another
* traveller checks off shows on every day plan at once. Only the visits that
* read as visits count; an event that names no day or no list changes nothing.
* Returns the function that stops listening.
*/
export function watchTourVisits(tripId: number | string): () => void {
const onEvent = (event: WebSocketEvent) => {
if (event?.type !== 'tours:visits' || String(event.tripId ?? '') !== String(tripId)) return;
const { assignmentId, visits } = event;
if (!isDayId(assignmentId) || !Array.isArray(visits)) return;
const parsed = visits.flatMap((raw) => {
const visit = tourVisitSchema.safeParse(raw);
return visit.success ? [visit.data] : [];
});
void useTourVisitStore.getState().receive(tripId, assignmentId, parsed);
};
addListener(onEvent);
return () => removeListener(onEvent);
}
+46 -12
View File
@@ -14,8 +14,9 @@ import { getOfflinePrefs } from './offlinePrefs'
import { randomId } from '../utils/randomId'
import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared'
import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, tourVisitListResponseSchema, type TourCreateResponse } from '@trek/shared'
import { cacheAssignment } from '../db/cacheAssignment'
import { replaceAssignmentVisits } from '../db/cacheTourVisits'
import type { Assignment, AssignmentParticipant, BudgetItem, BudgetItemMember, Day, DayNote, Reservation, Trip, TripFile } from '../types'
// Map Dexie table names used in `resource` field → actual Dexie tables.
@@ -237,9 +238,13 @@ const TEMP_REF_FIELDS = ['place_id', 'reservation_id', 'accommodation_id'] as co
// booking creates alongside names its place.
const TEMP_REF_OBJECTS = ['create_accommodation'] as const
// Resources whose writes carry no version token (see the 409 branch of the flush).
const UNVERSIONED_RESOURCES = new Set(['assignments', 'days', 'dayNotes'])
const UNVERSIONED_RESOURCES = new Set(['assignments', 'days', 'dayNotes', 'tourStationVisits'])
// A new order of a day's visits can name visits created offline.
const TEMP_REF_LISTS = ['orderedIds'] as const
// The 404s of a walk's mark that name a part of it that is gone: the trip, the
// day, the walk or the stop (TripAccessGuard, TourVisitsService). Any other 404,
// the Tours addon turned off among them, fails like any other refusal.
const GONE_WALK_PARTS = new Set(['Trip not found', 'Assignment not found', 'City walk not found', 'Station not found'])
/**
* The body with every link to an offline-created entity pointed at the real id
@@ -405,13 +410,38 @@ async function reconcileDayNote(mutation: QueuedMutation, data: unknown, idMap:
if (mutation.tempId !== undefined && mutation.tempId !== note.id) await remapTempId(mutation, note.id, idMap)
}
/** Visits, days, day notes and the trip itself, each from the answer its route gives. */
/**
* A replayed mark of a city walk's stop (CW-03): the day's visits as the server
* answers them. An answer that does not read as one is left alone rather than
* holding the queue up; the next read of the trip fetches them again.
*/
async function reconcileStationVisits(mutation: QueuedMutation, data: unknown, assignmentId: number | undefined): Promise<void> {
const answer = tourVisitListResponseSchema.safeParse(data)
if (!answer.success || assignmentId === undefined) return
await replaceAssignmentVisits(mutation.tripId, assignmentId, answer.data.visits)
}
/**
* A 404 that means the write did what it set out to do: a DELETE of something
* the server no longer has, or a mark of a walk's stop whose stop, day, walk or
* trip is gone, since its visits went with it.
*/
function doneByNotFound(mutation: QueuedMutation, err: unknown): boolean {
if (mutation.method === 'DELETE') return true
if (mutation.resource !== 'tourStationVisits') return false
// Called on a 404 only, so the server answered.
const reason = (err as { response: { data?: { error?: unknown } } }).response.data?.error
return typeof reason === 'string' && GONE_WALK_PARTS.has(reason)
}
/** Visits, days, day notes, the trip itself and a walk's marks, each from the answer its route gives. */
async function reconcilePlanWrite(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map<number, number>): Promise<void> {
if (mutation.method === 'DELETE') return
if (mutation.resource === 'assignments') await reconcileAssignment(mutation, data, entityId, idMap)
else if (mutation.resource === 'days') await reconcileDay(data)
else if (mutation.resource === 'dayNotes') await reconcileDayNote(mutation, data, idMap)
else if (mutation.resource === 'trips') await reconcileTrip(data)
else if (mutation.resource === 'tourStationVisits') await reconcileStationVisits(mutation, data, entityId)
}
/**
@@ -463,10 +493,10 @@ export const mutationQueue = {
* parked as failed or waiting as a conflict: sent straight to the server the
* new write would land first, and Try again or the next flush would replay
* the older one over it. Queued, it waits behind the older write and keeps
* the order the user made them in. The repos of visits, a day's visits, tours
* and the driving settings ask this, because those writes carry no version
* token; a place or packing item sends one, so the server refuses the older
* write (409) and it becomes a conflict instead.
* the order the user made them in. The repos of visits, a day's visits, tours,
* a walk's marks and the driving settings ask this, because those writes
* carry no version token; a place or packing item sends one, so the server
* refuses the older write (409) and it becomes a conflict instead.
*/
async mustQueue(resource: string, entityId: number): Promise<boolean> {
if (isEffectivelyOffline()) return true
@@ -493,7 +523,8 @@ export const mutationQueue = {
/**
* Drain the queue: replay each pending mutation against the server in FIFO order.
* Stops on the first network error (retried on the next trigger). 4xx answers
* are marked failed and skipped; a DELETE answered 404 counts as done. A 5xx
* are marked failed and skipped; a DELETE answered 404 counts as done, and so
* does a mark of a walk's stop whose stop, day or walk is gone. A 5xx
* holds back only its own trip, retried with growing gaps, and is parked as
* failed once it has failed MAX_SERVER_ERROR_ATTEMPTS times. A write parked as
* failed or as a conflict holds back the later writes to the same entity, so
@@ -708,7 +739,10 @@ export const mutationQueue = {
// A DELETE of something the server no longer has did what it set out
// to do. Reporting it as a failed sync would flag a change that worked.
if (httpStatus === 404 && mutation.method === 'DELETE') {
// So did a mark of a walk's stop whose stop, day or walk is gone:
// parked as failed, it would hold back every later mark of that day
// for nothing.
if (httpStatus === 404 && doneByNotFound(mutation, err)) {
if (mutation.resource && reqEntityId !== undefined) {
const table = getTable(mutation.resource)
if (table) await table.delete(reqEntityId)
@@ -722,9 +756,9 @@ export const mutationQueue = {
// the user's strategy instead of failing it. Deliberately scoped to
// edits: an offline DELETE is "delete wins" by design (no CAS on the
// delete path), so it never reaches here. See the wiki Offline doc.
// Visits, days and day notes carry no version, so a 409 there is a
// refusal (a Tour already on that day) that no retry can change, and
// it fails like any other 4xx.
// Visits, days, day notes and a walk's marks carry no version, so a
// 409 there is a refusal (a Tour already on that day) that no retry
// can change, and it fails like any other 4xx.
if (httpStatus === 409 && mutation.method !== 'DELETE' && !UNVERSIONED_RESOURCES.has(mutation.resource ?? '')) {
const server = extractConflictServer(err)
const strategy = getOfflinePrefs().conflictStrategy
+9 -2
View File
@@ -17,6 +17,7 @@ import { tripSyncManager } from './tripSyncManager'
import { isEffectivelyOnline, onNetworkModeChange } from './networkMode'
import { setPreReconnectHook, setRefetchCallback, getActiveTrips, reconnectNow } from '../api/websocket'
import { useTripStore } from '../store/tripStore'
import { reloadTourVisits } from '../store/tourVisitStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAuthStore } from '../store/authStore'
@@ -41,11 +42,15 @@ function ensureSettingsLoaded() {
useSettingsStore.getState().loadSettings().catch(console.error)
}
/** Pull the latest server state for every open trip into the Zustand store. */
/**
* Pull the latest server state for every open trip into the Zustand store,
* and the visits of its city walks, now that the queued marks were sent.
*/
function rehydrateActiveTrips() {
const store = useTripStore.getState()
for (const tripId of getActiveTrips()) {
store.hydrateActiveTrip(tripId).catch(console.error)
reloadTourVisits(tripId)
}
}
@@ -107,9 +112,11 @@ export function registerSyncTriggers(): void {
// limiters when the socket drops and reconnects while the device is online.
setPreReconnectHook(() => mutationQueue.flush())
// After the reconnect flush, pull canonical state for the open trip back into
// the store (the WS layer awaits the flush hook before invoking this).
// the store (the WS layer awaits the flush hook before invoking this), and
// the walk visits, so a mark the replay dropped or failed leaves the day plan.
setRefetchCallback(tripId => {
useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error)
reloadTourVisits(tripId)
})
window.addEventListener('online', onOnline)
+21 -1
View File
@@ -1,4 +1,5 @@
import { roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
import { tourRepo } from '../repo/tourRepo'
/**
* Trip sync manager — seeds Dexie with trip data for offline use.
*
@@ -12,6 +13,8 @@ import { roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
* - WS reconnect (phase 7)
*/
import { tripsApi, tagsApi, categoriesApi, toursApi } from '../api/client'
import { tourVisitsApi } from '../api/tourVisits'
import { replaceTripVisits } from '../db/cacheTourVisits'
import {
offlineDb,
upsertTrip,
@@ -153,13 +156,30 @@ async function syncTrip(tripId: number): Promise<void> {
// sync of many trips does not spend a refused request on each.
if (useAddonStore.getState().isEnabled('tours')) {
try {
await replaceTripTours(tripId, (await toursApi.list(tripId)).tours)
await cacheTripTours(tripId)
} catch (err) {
console.warn(`[sync] tours for trip ${tripId} not cached:`, err)
}
}
}
/**
* A trip's tours and, for its city walks, the stops checked off on their days
* (CW-03), so a walk still shows how far it got offline. A walk whose stops
* this device never read, one planned on another device, has them read here
* too, each walk on its own: one that cannot be read leaves the others alone,
* and offline its card then lists no stops to check off.
*/
async function cacheTripTours(tripId: number): Promise<void> {
const { tours } = await toursApi.list(tripId)
await replaceTripTours(tripId, tours)
const walks = tours.filter(tour => tour.tour_type === 'walk')
await replaceTripVisits(tripId, walks.length > 0 ? (await tourVisitsApi.list(tripId)).visits : [])
const cached = await offlineDb.tours.bulkGet(walks.map(walk => walk.place_id))
const unread = walks.filter((_walk, index) => !cached[index]?.waypoints?.length)
await Promise.allSettled(unread.map(walk => tourRepo.detail(tripId, walk.place_id)))
}
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
async function cacheFilesForTrip(tripId: number, files: TripFile[]): Promise<void> {
const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f))