diff --git a/client/src/api/tourVisits.ts b/client/src/api/tourVisits.ts index 550204873..2e1166d6e 100644 --- a/client/src/api/tourVisits.ts +++ b/client/src/api/tourVisits.ts @@ -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 => @@ -11,7 +16,5 @@ export const tourVisitsApi = { stationKey: string, body: TourVisitUpdateRequest ): Promise => - 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), }; diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 0d016abac..a7db00eda 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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', diff --git a/client/src/components/Tours/TourDayRowFacts.test.tsx b/client/src/components/Tours/TourDayRowFacts.test.tsx index 0c2e08fc1..d80ae7cf1 100644 --- a/client/src/components/Tours/TourDayRowFacts.test.tsx +++ b/client/src/components/Tours/TourDayRowFacts.test.tsx @@ -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( +
+ +
+ ); + 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( + <> + + + + ); + 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) => diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index a00199aa1..c765b9db3 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -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) => ( ))} + {progress && ( + + )} ); } diff --git a/client/src/components/Tours/walk/WalkStations.test.tsx b/client/src/components/Tours/walk/WalkStations.test.tsx new file mode 100644 index 000000000..5bd839631 --- /dev/null +++ b/client/src/components/Tours/walk/WalkStations.test.tsx @@ -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 { + return Promise.resolve({ tour: walk, waypoints: points }); +} + +function card(props: Partial[0]> = {}) { + return render( + <> + + + + ); +} + +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(); + 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( + <> + + + + + + ); + 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( + <> + + + + ); + 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'); + }); +}); diff --git a/client/src/components/Tours/walk/WalkStations.tsx b/client/src/components/Tours/walk/WalkStations.tsx index 7e696ee52..d780b9576 100644 --- a/client/src/components/Tours/walk/WalkStations.tsx +++ b/client/src/components/Tours/walk/WalkStations.tsx @@ -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 ; + const progress = useWalkProgress(tour, assignmentId); + if (!progress) return null; + return ( + + ); +} + +/** + * 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 && }>{t('tours.walk.hours.source')}} {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 ( {t('tours.walk.progress', { visited, total: stations.length })} + // No count where the stations could not be read: it would contradict the day row's. + assignmentId !== null && listed.length > 0 ? ( + {t('tours.walk.progress', { visited, total: listed.length })} ) : undefined } >
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'} />
    - {(stations ?? []).map((station, index) => ( + {listed.map((station, index) => ( onMark(station.key ?? '', next) : null} + onMark={marking === 'ready' && station.key ? (next) => onMark(station.key ?? '', next) : null} /> ))}
diff --git a/client/src/components/Tours/walk/useTourVisitsSync.test.ts b/client/src/components/Tours/walk/useTourVisitsSync.test.ts new file mode 100644 index 000000000..ce7388eb7 --- /dev/null +++ b/client/src/components/Tours/walk/useTourVisitsSync.test.ts @@ -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() { + let settle: (value: T) => void = () => undefined; + let fail: (reason: unknown) => void = () => undefined; + const promise = new Promise((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' } } }); + }); +}); diff --git a/client/src/components/Tours/walk/useTourVisitsSync.ts b/client/src/components/Tours/walk/useTourVisitsSync.ts index ec9185466..85e5c0487 100644 --- a/client/src/components/Tours/walk/useTourVisitsSync.ts +++ b/client/src/components/Tours/walk/useTourVisitsSync.ts @@ -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]); } diff --git a/client/src/components/Tours/walk/walkProgress.test.ts b/client/src/components/Tours/walk/walkProgress.test.ts new file mode 100644 index 000000000..feea6bc48 --- /dev/null +++ b/client/src/components/Tours/walk/walkProgress.test.ts @@ -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' }); + }); +}); diff --git a/client/src/components/Tours/walk/walkProgress.ts b/client/src/components/Tours/walk/walkProgress.ts new file mode 100644 index 000000000..439a20f2d --- /dev/null +++ b/client/src/components/Tours/walk/walkProgress.ts @@ -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, + 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, + assignmentId: number | null | undefined +): { visited: number; total: number } | null { + return walkProgressOf(tour, useDayVisits(assignmentId)); +} diff --git a/client/src/db/cacheTourVisits.ts b/client/src/db/cacheTourVisits.ts new file mode 100644 index 000000000..abc1ed41a --- /dev/null +++ b/client/src/db/cacheTourVisits.ts @@ -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 { + 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 { + 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 { + 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 })); +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 47cd8f957..9a8f0ac3c 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -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; budgetSettlements!: Table; ferrataTiles!: Table; + tourStationVisits!: Table; 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 { 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 { 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(); diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx index eb545ed92..0e5b7001d 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx @@ -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(); + 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(); + expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index 5b0e20cfd..a0c585354 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -200,7 +200,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro )}
- {tour &&
} + {tour &&
} {(time || sub) && (
{time && {time}} diff --git a/client/src/repo/tourVisitRepo.offline.test.ts b/client/src/repo/tourVisitRepo.offline.test.ts new file mode 100644 index 000000000..c8000a63a --- /dev/null +++ b/client/src/repo/tourVisitRepo.offline.test.ts @@ -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 { + 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' }); + }); +}); diff --git a/client/src/repo/tourVisitRepo.test.ts b/client/src/repo/tourVisitRepo.test.ts new file mode 100644 index 000000000..f1f77a845 --- /dev/null +++ b/client/src/repo/tourVisitRepo.test.ts @@ -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']); + }); +}); diff --git a/client/src/repo/tourVisitRepo.ts b/client/src/repo/tourVisitRepo.ts index 368b316c7..faaf88fdd 100644 --- a/client/src/repo/tourVisitRepo.ts +++ b/client/src/repo/tourVisitRepo.ts @@ -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 { + 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 { - 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 { - 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 { + const id = Number(tripId); + cacheAfterWrite(replaceAssignmentVisits(id, assignmentId, visits), 'a city walk was checked off elsewhere'); + return ofDay(await withQueuedMarks(id, visits), assignmentId); }, }; diff --git a/client/src/store/tourVisitStore.test.ts b/client/src/store/tourVisitStore.test.ts new file mode 100644 index 000000000..ef78783c8 --- /dev/null +++ b/client/src/store/tourVisitStore.test.ts @@ -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() { + let settle: (value: T) => void = () => undefined; + const promise = new Promise((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); + }); +}); diff --git a/client/src/store/tourVisitStore.ts b/client/src/store/tourVisitStore.ts index 12d069ba7..d103b3483 100644 --- a/client/src/store/tourVisitStore.ts +++ b/client/src/store/tourVisitStore.ts @@ -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>; +export type VisitsByStation = Readonly>; 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>; @@ -20,7 +23,9 @@ interface TourVisitState { stationKey: string, status: TourVisitStatus | 'planned' ) => Promise; - /** 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; + /** 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((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> = {}; 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((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); +} diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index fb6500303..596ad1dcf 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -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 { + 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): Promise { 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 { 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 diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 76ec882b5..505503f65 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -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) diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index e54e1e53c..29e14054d 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -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 { // 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 { + 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 { const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f)) diff --git a/client/tests/unit/db/offlineDb.test.ts b/client/tests/unit/db/offlineDb.test.ts index d8c711b39..6bec404d5 100644 --- a/client/tests/unit/db/offlineDb.test.ts +++ b/client/tests/unit/db/offlineDb.test.ts @@ -343,6 +343,21 @@ describe('offlineDb — clearTripData', () => { expect(await offlineDb.areaPlaces.where('tripId').equals(2).count()).toBe(1); }); + it("takes the trip's city walk visits with it, and leaves another trip its own", async () => { + await upsertTrip(makeTrip(1)); + await upsertTrip(makeTrip(2)); + await offlineDb.tourStationVisits.bulkPut([ + { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 1 }, + { assignment_id: 71, station_key: 'stop-02', status: 'skipped', trip_id: 1 }, + { assignment_id: 81, station_key: 'stop-01', status: 'visited', trip_id: 2 }, + ]); + + await clearTripData(1); + + expect(await offlineDb.tourStationVisits.where('trip_id').equals(1).count()).toBe(0); + expect(await offlineDb.tourStationVisits.get([81, 'stop-01'])).toMatchObject({ trip_id: 2 }); + }); + it('preserves unsynced, conflicting and parked failed writes (#1135)', async () => { await upsertTrip(makeTrip(1)); await offlineDb.mutationQueue.bulkPut([ diff --git a/client/tests/unit/db/offlineDb.upgrade.test.ts b/client/tests/unit/db/offlineDb.upgrade.test.ts index e2c3aacd9..3564cec42 100644 --- a/client/tests/unit/db/offlineDb.upgrade.test.ts +++ b/client/tests/unit/db/offlineDb.upgrade.test.ts @@ -54,6 +54,7 @@ const HISTORY: Record = { 9: { tours: 'place_id, trip_id' }, 10: { budgetSettlements: 'tripId' }, 11: { ferrataTiles: '[tripId+tile], tripId, fetchedAt' }, + 12: { tourStationVisits: '[assignment_id+station_key], trip_id, assignment_id' }, }; const HISTORY_VERSIONS = Object.keys(HISTORY) @@ -132,6 +133,7 @@ function rowsAt(version: number): Record> { tours: { place_id: 21, trip_id: 1, name: 'Old town loop', distance: 4200 }, budgetSettlements: { tripId: 1, base: 'EUR', settlement: { balances: [], flows: [] } }, ferrataTiles: { tripId: 1, tile: '930:236', fetchedAt: 11, ferratas: [] }, + tourStationVisits: { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 1 }, }; const tables = schemaAt(version); return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables)); @@ -308,6 +310,22 @@ const UPGRADE_CASES: Record = { expect(await db.ferrataTiles.where('fetchedAt').below(6).count()).toBe(1); }, }, + 12: { + intent: 'adds the visits of the city walks per day and keeps the via ferrata tiles', + check: async (db) => { + expect(await db.ferrataTiles.get([1, '930:236'])).toMatchObject({ fetchedAt: 11 }); + await db.table('tourStationVisits').bulkPut([ + { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 3 }, + { assignment_id: 71, station_key: 'stop-02', status: 'skipped', trip_id: 3 }, + // The same station on another day of the walk is its own row. + { assignment_id: 72, station_key: 'stop-01', status: 'visited', trip_id: 3 }, + ]); + expect(await db.tourStationVisits.where('trip_id').equals(3).count()).toBe(3); + expect(await db.tourStationVisits.where('assignment_id').equals(71).count()).toBe(2); + expect(await db.tourStationVisits.get([71, 'stop-01'])).toMatchObject({ status: 'visited' }); + expect(await db.tourStationVisits.get([72, 'stop-01'])).toMatchObject({ status: 'visited' }); + }, + }, }; describe('offlineDb upgrades', () => { diff --git a/client/tests/unit/sync/mutationQueue.tourVisits.test.ts b/client/tests/unit/sync/mutationQueue.tourVisits.test.ts new file mode 100644 index 000000000..05014ec17 --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.tourVisits.test.ts @@ -0,0 +1,229 @@ +/** + * mutationQueue replay of a city walk's marks made offline (CW-03): each goes + * out with its idempotency key in the order it was made, the day's answer is + * kept on this device, a 409 fails instead of waiting as a conflict, a 404 for + * a stop, day, walk or trip that is gone counts as done so the later marks of + * that day still go out while any other 404 fails, and an answer that does not + * read as visits holds nothing up. + */ +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { tourVisitPath } from '../../../src/api/tourVisits'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; +import { tourVisitRepo } from '../../../src/repo/tourVisitRepo'; +import { useAddonStore } from '../../../src/store/addonStore'; +import { reloadTourVisits, useTourVisitStore } from '../../../src/store/tourVisitStore'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { server } from '../../helpers/msw/server'; + +function setOnline(value: boolean): void { + Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true }); +} + +const cached = async () => + (await offlineDb.tourStationVisits.toArray()) + .map((row) => `${row.assignment_id}:${row.station_key}:${row.status}`) + .sort(); + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); + await offlineDb.tourStationVisits.bulkPut([ + { assignment_id: 9, station_key: 'stop-02', status: 'skipped', trip_id: 4 }, + { assignment_id: 10, station_key: 'stop-01', status: 'visited', trip_id: 4 }, + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); + useAddonStore.setState({ addons: [] }); + useTourVisitStore.setState({ tripId: null, byAssignment: {} }); +}); + +describe('mutationQueue.flush, city walk marks', () => { + it('FE-SYNC-VISITS-001: a mark replays with its key and the day it answers is kept here', async () => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + const [queued] = await offlineDb.mutationQueue.toArray(); + const keys: Array = []; + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', ({ request }) => { + keys.push(request.headers.get('X-Idempotency-Key')); + return HttpResponse.json({ + visits: [ + { assignment_id: 9, station_key: 'stop-01', status: 'visited' }, + { assignment_id: 9, station_key: 'stop-02', status: 'skipped' }, + ], + }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(keys).toEqual([queued.id]); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await cached()).toEqual(['10:stop-01:visited', '9:stop-01:visited', '9:stop-02:skipped']); + expect(await offlineDb.tourStationVisits.get([9, 'stop-01'])).toMatchObject({ trip_id: 4 }); + }); + + it('FE-SYNC-VISITS-002: a mark and its take back of the same stop go out in the order they were made', async () => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'planned' }); + const sent: unknown[] = []; + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', async ({ request }) => { + const body = (await request.json()) as { status: string }; + sent.push(body.status); + const visits = + body.status === 'visited' ? [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] : []; + return HttpResponse.json({ visits }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(sent).toEqual(['visited', 'planned']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await cached()).toEqual(['10:stop-01:visited']); + }); + + it('FE-SYNC-VISITS-003: a mark refused with 409 fails, it does not wait as a conflict', async () => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', () => HttpResponse.json({ error: 'Conflict' }, { status: 409 })) + ); + setOnline(true); + + await mutationQueue.flush(); + + const [row] = await offlineDb.mutationQueue.toArray(); + expect(row.status).toBe('failed'); + expect(await mutationQueue.conflictCount()).toBe(0); + }); + + it('FE-SYNC-VISITS-004: a mark of a stop that is gone counts as done, and the next mark of the day still goes out', async () => { + await tourVisitRepo.update(4, 9, 'gone-01', { status: 'visited' }); + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'skipped' }); + const sent: string[] = []; + server.use( + http.put('/api/trips/4/tour-visits/9/gone-01', () => { + sent.push('gone-01'); + return HttpResponse.json({ error: 'Station not found' }, { status: 404 }); + }), + http.put('/api/trips/4/tour-visits/9/stop-01', () => { + sent.push('stop-01'); + return HttpResponse.json({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'skipped' }] }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(sent).toEqual(['gone-01', 'stop-01']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await mutationQueue.failedCount()).toBe(0); + expect(await cached()).toEqual(['10:stop-01:visited', '9:stop-01:skipped']); + }); + + it.each(['Trip not found', 'Assignment not found', 'City walk not found'])( + 'FE-SYNC-VISITS-004: a mark answered 404 "%s" counts as done too', + async (reason) => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', () => HttpResponse.json({ error: reason }, { status: 404 })) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + } + ); + + it('FE-SYNC-VISITS-006: a 404 that names no part of the walk as gone fails and is kept, the Tours addon turned off among them', async () => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + await tourVisitRepo.update(4, 10, 'stop-01', { status: 'skipped' }); + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', () => + HttpResponse.json({ error: 'Tours addon is not enabled' }, { status: 404 }) + ), + http.put('/api/trips/4/tour-visits/10/stop-01', () => new HttpResponse('Not Found', { status: 404 })) + ); + setOnline(true); + + await mutationQueue.flush(); + + const rows = await offlineDb.mutationQueue.where('tripId').equals(4).sortBy('createdAt'); + expect(rows.map((row) => [row.entityId, row.status])).toEqual([ + [9, 'failed'], + [10, 'failed'], + ]); + expect(await cached()).toEqual(['10:stop-01:visited', '9:stop-02:skipped']); + }); + + it('FE-SYNC-VISITS-007: a mark the replay dropped leaves the day plan once the visits are read again', async () => { + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: 'map', enabled: true }] }); + await useTourVisitStore.getState().load(4); + await useTourVisitStore.getState().mark(4, 9, 'gone-01', 'visited'); + expect(useTourVisitStore.getState().byAssignment[9]).toEqual({ 'stop-02': 'skipped', 'gone-01': 'visited' }); + server.use( + http.put('/api/trips/4/tour-visits/9/gone-01', () => + HttpResponse.json({ error: 'Station not found' }, { status: 404 }) + ), + http.get('/api/trips/4/tour-visits', () => + HttpResponse.json({ visits: [{ assignment_id: 9, station_key: 'stop-02', status: 'skipped' }] }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + expect(useTourVisitStore.getState().byAssignment[9]).toHaveProperty('gone-01'); + reloadTourVisits(4); + + await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-02': 'skipped' } })); + }); + + it('FE-SYNC-VISITS-008: a queued mark that names no day replays and leaves the kept visits alone', async () => { + await mutationQueue.enqueue({ + id: 'mark-without-day', + tripId: 4, + method: 'PUT', + url: tourVisitPath(4, 9, 'stop-01'), + body: { status: 'visited' }, + resource: 'tourStationVisits', + }); + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', () => + HttpResponse.json({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await cached()).toEqual(['10:stop-01:visited', '9:stop-02:skipped']); + }); + + it('FE-SYNC-VISITS-005: an answer that does not read as visits leaves the kept ones and holds nothing up', async () => { + await tourVisitRepo.update(4, 9, 'stop-01', { status: 'visited' }); + await tourVisitRepo.update(4, 10, 'stop-01', { status: 'planned' }); + server.use( + http.put('/api/trips/4/tour-visits/9/stop-01', () => HttpResponse.json({ ok: true })), + http.put('/api/trips/4/tour-visits/10/stop-01', () => HttpResponse.json({ visits: [] })) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await cached()).toEqual(['9:stop-02:skipped']); + }); +}); diff --git a/client/tests/unit/sync/syncTriggers.test.ts b/client/tests/unit/sync/syncTriggers.test.ts index ed579928f..10db13d0e 100644 --- a/client/tests/unit/sync/syncTriggers.test.ts +++ b/client/tests/unit/sync/syncTriggers.test.ts @@ -10,6 +10,7 @@ const flush = vi.fn(() => Promise.resolve()); const syncAll = vi.fn(() => Promise.resolve()); const hydrate = vi.fn(() => Promise.resolve()); const loadSettings = vi.fn(() => Promise.resolve()); +const reloadVisits = vi.fn(); let refetchCb: ((tripId: string) => void) | null = null; let preReconnect: (() => Promise) | null = null; @@ -32,6 +33,9 @@ vi.mock('../../../src/api/websocket', () => ({ vi.mock('../../../src/store/tripStore', () => ({ useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) }, })); +vi.mock('../../../src/store/tourVisitStore', () => ({ + reloadTourVisits: (tripId: string) => reloadVisits(tripId), +})); vi.mock('../../../src/store/settingsStore', () => ({ useSettingsStore: { getState: () => ({ isLoaded: settingsLoaded, loadSettings }) }, })); @@ -46,7 +50,7 @@ const flushMicrotasks = async () => { }; beforeEach(() => { - flush.mockClear(); syncAll.mockClear(); hydrate.mockClear(); loadSettings.mockClear(); + flush.mockClear(); syncAll.mockClear(); hydrate.mockClear(); loadSettings.mockClear(); reloadVisits.mockClear(); refetchCb = null; preReconnect = null; reconnects = 0; settingsLoaded = false; authenticated = true; Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); @@ -64,6 +68,12 @@ describe('syncTriggers', () => { expect(hydrate).toHaveBeenCalledWith('7'); }); + it('the refetch after a reconnect also reads the walk visits of the trip again (CW-03)', () => { + registerSyncTriggers(); + refetchCb?.('7'); + expect(reloadVisits).toHaveBeenCalledWith('7'); + }); + it('also registers the pre-reconnect flush hook', () => { registerSyncTriggers(); expect(preReconnect).toBeTypeOf('function'); @@ -88,6 +98,9 @@ describe('syncTriggers', () => { // overwrite edits that have not been sent yet. expect(flush.mock.invocationCallOrder[0]).toBeLessThan(syncAll.mock.invocationCallOrder[0]); expect(flush.mock.invocationCallOrder[0]).toBeLessThan(hydrate.mock.invocationCallOrder[0]); + // CW-03: the walk visits are read again only once the queued marks went out. + expect(reloadVisits).toHaveBeenCalledWith('7'); + expect(flush.mock.invocationCallOrder[0]).toBeLessThan(reloadVisits.mock.invocationCallOrder[0]); }); it('online event and a tab coming back reconnect the socket right away instead of waiting out its backoff', async () => { diff --git a/client/tests/unit/sync/tripSyncManager.test.ts b/client/tests/unit/sync/tripSyncManager.test.ts index 340338264..a24b20dbf 100644 --- a/client/tests/unit/sync/tripSyncManager.test.ts +++ b/client/tests/unit/sync/tripSyncManager.test.ts @@ -342,6 +342,61 @@ describe('tripSyncManager.syncAll — tours', () => { expect(await offlineDb.places.where('trip_id').equals(312).count()).toBe(1); expect(await offlineDb.syncMeta.get(312)).toBeDefined(); }); + + // CW-03: a city walk shows how far it got offline, and its stops can be checked off there. + describe('city walks', () => { + const walk = (place_id: number) => ({ ...tour, place_id, name: `Walk ${place_id}`, tour_type: 'walk', max_hiking_difficulty: null, station_count: 1 }); + const stops = [{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum' }]; + + /** Serves trip 313 with these tours and visits; returns the tours whose detail was read and whether the visits were asked. */ + function serveWalks(tours: unknown[], visits: unknown[]) { + const read: number[] = []; + let visitsAsked = 0; + server.use( + http.get('/api/trips', () => HttpResponse.json({ trips: [buildTrip({ id: 313, end_date: dateOffset(5) })] })), + http.get('/api/trips/313/bundle', () => HttpResponse.json(makeBundle(313))), + http.get('/api/trips/313/tours', () => HttpResponse.json({ tours })), + http.get('/api/trips/313/tour-visits', () => { visitsAsked++; return HttpResponse.json({ visits }); }), + http.get('/api/trips/313/tours/:placeId', ({ params }) => { + const placeId = Number(params.placeId); + read.push(placeId); + if (placeId === 903) return HttpResponse.json({ error: 'boom' }, { status: 500 }); + return HttpResponse.json({ tour: walk(placeId), waypoints: stops }); + }), + ); + return { read, visitsAsked: () => visitsAsked }; + } + + beforeEach(() => useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })); + + it("keeps the trip's visits, and reads the stops of each walk this device has none of", async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await offlineDb.tours.put({ ...walk(902), trip_id: 313, waypoints: stops } as never); + await offlineDb.tourStationVisits.put({ assignment_id: 70, station_key: 'gone-01', status: 'visited', trip_id: 313 }); + const served = serveWalks([tour, walk(901), walk(902), walk(903)], [{ assignment_id: 71, station_key: 'stop-01', status: 'visited' }]); + + await tripSyncManager.syncAll(); + + expect((await offlineDb.tourStationVisits.toArray()).map(v => `${v.trip_id}:${v.assignment_id}:${v.station_key}`)).toEqual(['313:71:stop-01']); + expect(served.read.sort()).toEqual([901, 903]); + expect((await offlineDb.tours.get(901))?.waypoints).toEqual(stops); + expect((await offlineDb.tours.get(902))?.waypoints).toEqual(stops); + expect((await offlineDb.tours.get(903))?.waypoints).toBeUndefined(); + // A walk whose stops could not be read is no failure of the tours. + expect(warn).not.toHaveBeenCalled(); + }); + + it('asks for no visits on a trip without a city walk, and keeps none of it', async () => { + await offlineDb.tourStationVisits.put({ assignment_id: 70, station_key: 'gone-01', status: 'visited', trip_id: 313 }); + const served = serveWalks([tour], []); + + await tripSyncManager.syncAll(); + + expect(served.visitsAsked()).toBe(0); + expect(served.read).toEqual([]); + expect(await offlineDb.tourStationVisits.count()).toBe(0); + }); + }); }); // ── file blob caching ────────────────────────────────────────────────────────── diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index e66f58522..c7b751023 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -505,6 +505,7 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'محسوب', 'tours.walk.stations': 'محطات الجولة', 'tours.walk.stationNumbered': 'المحطة {number}', + 'tours.walk.stationsUnread': 'تعذّر تحميل محطات هذه الجولة. افتحها مرة أخرى وأنت متصل بالإنترنت لعرضها وتأشيرها.', 'tours.walk.progress': 'تمت زيارة {visited} من {total}', 'tours.walk.visit.visited': 'تمت الزيارة', 'tours.walk.visit.skip': 'تخطي', @@ -512,7 +513,8 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'تمت زيارتها في هذا اليوم', 'tours.walk.visit.isSkipped': 'تم تخطيها في هذا اليوم', 'tours.walk.visit.fromDay': 'افتح الجولة من يوم في الخطة لتأشير محطاتها لذلك اليوم.', - 'tours.walk.visit.failed': 'تعذر تأشير المحطة. يتطلب ذلك اتصالًا؛ حاول مجددًا بعد قليل.', + 'tours.walk.visit.failed': 'تعذر تأشير المحطة. افتح الجولة مرة أخرى وحاول من جديد.', + 'tours.walk.visit.notSynced': 'أُضيفت هذه الجولة إلى اليوم دون اتصال. يمكن تأشير محطاتها بعد مزامنتها.', 'tours.walk.themes': 'موضوع الجولة', 'tours.walk.theme.culture': 'ثقافة وتاريخ', 'tours.walk.theme.parks': 'حدائق وإطلالات', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 2fcbb8e12..cf0b91ff1 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -500,6 +500,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Hesablanmış', 'tours.walk.stations': 'Gəzintinin dayanacaqları', 'tours.walk.stationNumbered': 'Dayanacaq {number}', + 'tours.walk.stationsUnread': + 'Bu gəzintinin dayanacaqları yüklənə bilmədi. Onları görmək və qeyd etmək üçün gəzintini internet bağlantısı ilə yenidən aç.', 'tours.walk.progress': '{total} dayanacaqdan {visited} ziyarət edildi', 'tours.walk.visit.visited': 'Ziyarət edildi', 'tours.walk.visit.skip': 'Keç', @@ -507,7 +509,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Bu gün ziyarət edildi', 'tours.walk.visit.isSkipped': 'Bu gün keçildi', 'tours.walk.visit.fromDay': 'Dayanacaqları o gün üçün qeyd etmək üçün gəzintini plandakı bir gündən aç.', - 'tours.walk.visit.failed': 'Dayanacaq qeyd edilə bilmədi. Bunun üçün bağlantı lazımdır; bir azdan yenidən cəhd et.', + 'tours.walk.visit.failed': 'Dayanacaq qeyd edilə bilmədi. Gəzintini yenidən aç və bir daha cəhd et.', + 'tours.walk.visit.notSynced': + 'Bu gəzinti günə oflayn əlavə edilib. Sinxronlaşdıqdan sonra dayanacaqlarını qeyd etmək olar.', 'tours.walk.themes': 'Gəzinti mövzusu', 'tours.walk.theme.culture': 'Mədəniyyət və tarix', 'tours.walk.theme.parks': 'Parklar və mənzərələr', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index fa595b31d..64f74a8ad 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -506,6 +506,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas do passeio', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.stationsUnread': + 'Não foi possível carregar as paradas deste passeio. Abra-o de novo com conexão para vê-las e marcá-las.', 'tours.walk.progress': '{visited} de {total} visitadas', 'tours.walk.visit.visited': 'Visitada', 'tours.walk.visit.skip': 'Pular', @@ -513,7 +515,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visitada neste dia', 'tours.walk.visit.isSkipped': 'Pulada neste dia', 'tours.walk.visit.fromDay': 'Abra o passeio a partir de um dia do plano para marcar as paradas daquele dia.', - 'tours.walk.visit.failed': 'Não foi possível marcar a parada. É preciso conexão; tente de novo em instantes.', + 'tours.walk.visit.failed': 'Não foi possível marcar a parada. Abra o passeio de novo e tente outra vez.', + 'tours.walk.visit.notSynced': + 'Este passeio foi colocado no dia sem conexão. As paradas poderão ser marcadas assim que ele for sincronizado.', 'tours.walk.themes': 'Tema do passeio', 'tours.walk.theme.culture': 'Cultura e história', 'tours.walk.theme.parks': 'Parques e vistas', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index d92bfc20a..8689c7c4b 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -507,6 +507,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculat', 'tours.walk.stations': 'Parades de la ruta', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.stationsUnread': + "No s'han pogut carregar les parades d'aquesta ruta. Torna a obrir-la amb connexió per veure-les i marcar-les.", 'tours.walk.progress': '{visited} de {total} visitades', 'tours.walk.visit.visited': 'Visitada', 'tours.walk.visit.skip': 'Omet', @@ -514,7 +516,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visitada aquest dia', 'tours.walk.visit.isSkipped': 'Omesa aquest dia', 'tours.walk.visit.fromDay': "Obre la ruta des d'un dia del pla per marcar-ne les parades d'aquell dia.", - 'tours.walk.visit.failed': "No s'ha pogut marcar la parada. Cal connexió; torna-ho a provar d'aquí a una estona.", + 'tours.walk.visit.failed': "No s'ha pogut marcar la parada. Torna a obrir la ruta i prova-ho un altre cop.", + 'tours.walk.visit.notSynced': + "Aquesta ruta s'ha afegit al dia sense connexió. Podràs marcar-ne les parades quan s'hagi sincronitzat.", 'tours.walk.themes': 'Tema de la ruta', 'tours.walk.theme.culture': 'Cultura i història', 'tours.walk.theme.parks': 'Parcs i vistes', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index f0a5a10e4..264d0064a 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -497,6 +497,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Vypočteno', 'tours.walk.stations': 'Zastávky procházky', 'tours.walk.stationNumbered': 'Zastávka {number}', + 'tours.walk.stationsUnread': + 'Zastávky této procházky se nepodařilo načíst. Otevři ji znovu s připojením, pak je uvidíš a půjde je odškrtat.', 'tours.walk.progress': 'Navštíveno {visited} z {total}', 'tours.walk.visit.visited': 'Navštíveno', 'tours.walk.visit.skip': 'Přeskočit', @@ -504,7 +506,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Navštíveno v tento den', 'tours.walk.visit.isSkipped': 'Přeskočeno v tento den', 'tours.walk.visit.fromDay': 'Otevři procházku ze dne v plánu, abys mohl odškrtat zastávky pro ten den.', - 'tours.walk.visit.failed': 'Zastávku se nepodařilo označit. Je potřeba připojení; zkus to za chvíli znovu.', + 'tours.walk.visit.failed': 'Zastávku se nepodařilo označit. Otevři procházku znovu a zkus to ještě jednou.', + 'tours.walk.visit.notSynced': + 'Tato procházka byla přidána ke dni offline. Zastávky půjde odškrtat, jakmile se synchronizuje.', 'tours.walk.themes': 'Téma procházky', 'tours.walk.theme.culture': 'Kultura a historie', 'tours.walk.theme.parks': 'Parky a výhledy', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 92147dbf3..0a0578f65 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -511,6 +511,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Berechnet', 'tours.walk.stations': 'Stationen', 'tours.walk.stationNumbered': 'Station {number}', + 'tours.walk.stationsUnread': + 'Die Stationen dieses Walks ließen sich nicht laden. Öffne ihn erneut mit Verbindung, um sie zu sehen und abzuhaken.', 'tours.walk.progress': '{visited} von {total} besucht', 'tours.walk.visit.visited': 'Besucht', 'tours.walk.visit.skip': 'Überspringen', @@ -519,7 +521,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isSkipped': 'An diesem Tag übersprungen', 'tours.walk.visit.fromDay': 'Öffne den Walk über einen Tag im Plan, um seine Stationen für diesen Tag abzuhaken.', 'tours.walk.visit.failed': - 'Die Station konnte nicht markiert werden. Dafür braucht es eine Verbindung; versuch es gleich noch einmal.', + 'Die Station konnte nicht markiert werden. Öffne den Walk erneut und versuch es noch einmal.', + 'tours.walk.visit.notSynced': + 'Dieser Walk wurde offline auf den Tag gelegt. Seine Stationen lassen sich abhaken, sobald er synchronisiert ist.', 'tours.walk.themes': 'Thema des Walks', 'tours.walk.theme.culture': 'Kultur und Geschichte', 'tours.walk.theme.parks': 'Parks und Aussicht', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index bc238f2c0..d34055012 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -498,6 +498,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Computed', 'tours.walk.stations': 'Stops of the walk', 'tours.walk.stationNumbered': 'Stop {number}', + 'tours.walk.stationsUnread': + 'The stops of this walk could not be loaded. Open it again while online to see them and check them off.', 'tours.walk.progress': '{visited} of {total} visited', 'tours.walk.visit.visited': 'Mark visited', 'tours.walk.visit.skip': 'Skip', @@ -505,7 +507,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visited on this day', 'tours.walk.visit.isSkipped': 'Skipped on this day', 'tours.walk.visit.fromDay': 'Open the walk from a day of the plan to check its stops off for that day.', - 'tours.walk.visit.failed': 'The stop could not be marked. It needs a connection; try again in a moment.', + 'tours.walk.visit.failed': 'The stop could not be marked. Open the walk again and try once more.', + 'tours.walk.visit.notSynced': + 'This walk was put on the day while offline. Its stops can be checked off once it has synced.', 'tours.walk.themes': 'Walk theme', 'tours.walk.theme.culture': 'Culture and history', 'tours.walk.theme.parks': 'Parks and views', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index ed1552651..8da751ab4 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -505,6 +505,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas del paseo', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.stationsUnread': + 'No se pudieron cargar las paradas de este paseo. Vuelve a abrirlo con conexión para verlas y marcarlas.', 'tours.walk.progress': '{visited} de {total} visitadas', 'tours.walk.visit.visited': 'Visitada', 'tours.walk.visit.skip': 'Omitir', @@ -512,7 +514,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visitada este día', 'tours.walk.visit.isSkipped': 'Omitida este día', 'tours.walk.visit.fromDay': 'Abre el paseo desde un día del plan para marcar sus paradas de ese día.', - 'tours.walk.visit.failed': 'No se pudo marcar la parada. Hace falta conexión; inténtalo de nuevo en un momento.', + 'tours.walk.visit.failed': 'No se pudo marcar la parada. Vuelve a abrir el paseo e inténtalo otra vez.', + 'tours.walk.visit.notSynced': + 'Este paseo se añadió al día sin conexión. Podrás marcar sus paradas cuando se haya sincronizado.', 'tours.walk.themes': 'Tema del paseo', 'tours.walk.theme.culture': 'Cultura e historia', 'tours.walk.theme.parks': 'Parques y vistas', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index d48e851a7..ddd519aa9 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -500,6 +500,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Arvutatud', 'tours.walk.stations': 'Jalutuse peatused', 'tours.walk.stationNumbered': 'Peatus {number}', + 'tours.walk.stationsUnread': + 'Selle jalutuse peatusi ei õnnestunud laadida. Ava jalutus uuesti võrguühendusega, et neid näha ja ära märkida.', 'tours.walk.progress': '{visited} / {total} külastatud', 'tours.walk.visit.visited': 'Külastatud', 'tours.walk.visit.skip': 'Jäta vahele', @@ -507,7 +509,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Sel päeval külastatud', 'tours.walk.visit.isSkipped': 'Sel päeval vahele jäetud', 'tours.walk.visit.fromDay': 'Ava jalutus plaani päevast, et selle päeva peatused ära märkida.', - 'tours.walk.visit.failed': 'Peatust ei õnnestunud märkida. Selleks on vaja ühendust; proovi kohe uuesti.', + 'tours.walk.visit.failed': 'Peatust ei õnnestunud märkida. Ava jalutus uuesti ja proovi veel kord.', + 'tours.walk.visit.notSynced': + 'See jalutus lisati päevale võrguühenduseta. Selle peatusi saab ära märkida, kui see on sünkroonitud.', 'tours.walk.themes': 'Jalutuse teema', 'tours.walk.theme.culture': 'Kultuur ja ajalugu', 'tours.walk.theme.parks': 'Pargid ja vaated', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index b33350637..6a1dc6353 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -515,6 +515,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculé', 'tours.walk.stations': 'Étapes de la balade', 'tours.walk.stationNumbered': 'Étape {number}', + 'tours.walk.stationsUnread': + "Les étapes de cette balade n'ont pas pu être chargées. Rouvre-la avec une connexion pour les voir et les cocher.", 'tours.walk.progress': '{visited} sur {total} visitées', 'tours.walk.visit.visited': 'Visitée', 'tours.walk.visit.skip': 'Passer', @@ -522,7 +524,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visitée ce jour-là', 'tours.walk.visit.isSkipped': 'Passée ce jour-là', 'tours.walk.visit.fromDay': 'Ouvre la balade depuis un jour du plan pour cocher ses étapes ce jour-là.', - 'tours.walk.visit.failed': "L'étape n'a pas pu être cochée. Il faut une connexion ; réessaie dans un instant.", + 'tours.walk.visit.failed': "L'étape n'a pas pu être cochée. Rouvre la balade et réessaie.", + 'tours.walk.visit.notSynced': + "Cette balade a été ajoutée au jour hors connexion. Ses étapes pourront être cochées une fois qu'elle sera synchronisée.", 'tours.walk.themes': 'Thème de la balade', 'tours.walk.theme.culture': 'Culture et histoire', 'tours.walk.theme.parks': 'Parcs et points de vue', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 58f1087fd..23882a636 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -515,6 +515,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Υπολογισμένο', 'tours.walk.stations': 'Στάσεις της βόλτας', 'tours.walk.stationNumbered': 'Στάση {number}', + 'tours.walk.stationsUnread': + 'Δεν φορτώθηκαν οι στάσεις αυτής της βόλτας. Άνοιξέ την ξανά με σύνδεση για να τις δεις και να τις τσεκάρεις.', 'tours.walk.progress': '{visited} από {total} επισκέψεις', 'tours.walk.visit.visited': 'Επισκέφθηκα', 'tours.walk.visit.skip': 'Παράλειψη', @@ -522,7 +524,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Επισκέφθηκε αυτή τη μέρα', 'tours.walk.visit.isSkipped': 'Παραλείφθηκε αυτή τη μέρα', 'tours.walk.visit.fromDay': 'Άνοιξε τη βόλτα από μια μέρα του πλάνου για να τσεκάρεις τις στάσεις εκείνης της μέρας.', - 'tours.walk.visit.failed': 'Η στάση δεν σημειώθηκε. Χρειάζεται σύνδεση· δοκίμασε ξανά σε λίγο.', + 'tours.walk.visit.failed': 'Η στάση δεν σημειώθηκε. Άνοιξε ξανά τη βόλτα και δοκίμασε άλλη μία φορά.', + 'tours.walk.visit.notSynced': + 'Αυτή η βόλτα μπήκε στη μέρα χωρίς σύνδεση. Οι στάσεις της τσεκάρονται μόλις συγχρονιστεί.', 'tours.walk.themes': 'Θέμα βόλτας', 'tours.walk.theme.culture': 'Πολιτισμός και ιστορία', 'tours.walk.theme.parks': 'Πάρκα και θέα', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 6552cfb7d..2e2c7519a 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -507,6 +507,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Számított', 'tours.walk.stations': 'A séta megállói', 'tours.walk.stationNumbered': '{number}. megálló', + 'tours.walk.stationsUnread': + 'A séta megállóit nem sikerült betölteni. Nyisd meg újra internetkapcsolattal, hogy lásd és kipipálhasd őket.', 'tours.walk.progress': '{total} megállóból {visited} meglátogatva', 'tours.walk.visit.visited': 'Meglátogatva', 'tours.walk.visit.skip': 'Kihagyás', @@ -514,7 +516,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Ezen a napon meglátogatva', 'tours.walk.visit.isSkipped': 'Ezen a napon kihagyva', 'tours.walk.visit.fromDay': 'Nyisd meg a sétát a terv egyik napjáról, hogy arra a napra kipipáld a megállókat.', - 'tours.walk.visit.failed': 'A megállót nem sikerült megjelölni. Ehhez kapcsolat kell; próbáld újra kicsit később.', + 'tours.walk.visit.failed': 'A megállót nem sikerült megjelölni. Nyisd meg újra a sétát, és próbáld meg még egyszer.', + 'tours.walk.visit.notSynced': + 'Ezt a sétát offline tetted erre a napra. A megállói a szinkronizálás után pipálhatók ki.', 'tours.walk.themes': 'A séta témája', 'tours.walk.theme.culture': 'Kultúra és történelem', 'tours.walk.theme.parks': 'Parkok és kilátás', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 8839b2b91..2eddfc9ee 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -499,6 +499,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Dihitung', 'tours.walk.stations': 'Titik singgah rute', 'tours.walk.stationNumbered': 'Titik {number}', + 'tours.walk.stationsUnread': + 'Titik singgah rute ini tidak dapat dimuat. Buka lagi saat online untuk melihat dan mencentangnya.', 'tours.walk.progress': '{visited} dari {total} dikunjungi', 'tours.walk.visit.visited': 'Sudah dikunjungi', 'tours.walk.visit.skip': 'Lewati', @@ -506,7 +508,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Dikunjungi pada hari ini', 'tours.walk.visit.isSkipped': 'Dilewati pada hari ini', 'tours.walk.visit.fromDay': 'Buka rute dari salah satu hari di rencana untuk mencentang titiknya pada hari itu.', - 'tours.walk.visit.failed': 'Titik tidak dapat ditandai. Perlu koneksi; coba lagi sebentar lagi.', + 'tours.walk.visit.failed': 'Titik tidak dapat ditandai. Buka rute ini lagi lalu coba sekali lagi.', + 'tours.walk.visit.notSynced': + 'Rute ini ditambahkan ke hari itu saat offline. Titiknya bisa dicentang setelah tersinkron.', 'tours.walk.themes': 'Tema rute', 'tours.walk.theme.culture': 'Budaya dan sejarah', 'tours.walk.theme.parks': 'Taman dan pemandangan', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index d68e3ce6c..bae4a9e18 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -506,6 +506,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calcolato', 'tours.walk.stations': 'Tappe del percorso', 'tours.walk.stationNumbered': 'Tappa {number}', + 'tours.walk.stationsUnread': + 'Non è stato possibile caricare le tappe di questo percorso. Riaprilo con una connessione per vederle e spuntarle.', 'tours.walk.progress': '{visited} su {total} visitate', 'tours.walk.visit.visited': 'Visitata', 'tours.walk.visit.skip': 'Salta', @@ -513,7 +515,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Visitata in questo giorno', 'tours.walk.visit.isSkipped': 'Saltata in questo giorno', 'tours.walk.visit.fromDay': 'Apri il percorso da un giorno del piano per spuntare le tappe di quel giorno.', - 'tours.walk.visit.failed': 'Non è stato possibile segnare la tappa. Serve una connessione; riprova tra poco.', + 'tours.walk.visit.failed': 'Non è stato possibile segnare la tappa. Riapri il percorso e riprova.', + 'tours.walk.visit.notSynced': + 'Questo percorso è stato aggiunto al giorno offline. Potrai spuntarne le tappe appena sarà sincronizzato.', 'tours.walk.themes': 'Tema del percorso', 'tours.walk.theme.culture': 'Cultura e storia', 'tours.walk.theme.parks': 'Parchi e panorami', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index aa5b4dbd1..74d285e1a 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -491,6 +491,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '計算値', 'tours.walk.stations': '立ち寄り先', 'tours.walk.stationNumbered': '立ち寄り先 {number}', + 'tours.walk.stationsUnread': + 'この街歩きの立ち寄り先を読み込めませんでした。オンラインの状態で開き直すと、表示してチェックできます。', 'tours.walk.progress': '{total} 件中 {visited} 件訪問済み', 'tours.walk.visit.visited': '訪問済み', 'tours.walk.visit.skip': 'スキップ', @@ -498,8 +500,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'この日に訪問済み', 'tours.walk.visit.isSkipped': 'この日はスキップ', 'tours.walk.visit.fromDay': '計画の日から開くと、その日の立ち寄り先をチェックできます。', - 'tours.walk.visit.failed': - '立ち寄り先をチェックできませんでした。接続が必要です。少ししてからもう一度お試しください。', + 'tours.walk.visit.failed': '立ち寄り先をチェックできませんでした。街歩きを開き直して、もう一度お試しください。', + 'tours.walk.visit.notSynced': + 'この街歩きはオフラインでこの日に追加されました。同期が済むと立ち寄り先をチェックできます。', 'tours.walk.themes': '散策のテーマ', 'tours.walk.theme.culture': '文化と歴史', 'tours.walk.theme.parks': '公園と眺望', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 8e2b4a7c6..8ec3799a7 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -487,6 +487,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '계산값', 'tours.walk.stations': '산책 정류지', 'tours.walk.stationNumbered': '정류지 {number}', + 'tours.walk.stationsUnread': + '이 산책의 정류지를 불러오지 못했습니다. 온라인 상태에서 다시 열면 정류지를 보고 체크할 수 있습니다.', 'tours.walk.progress': '{total}곳 중 {visited}곳 방문', 'tours.walk.visit.visited': '방문함', 'tours.walk.visit.skip': '건너뛰기', @@ -494,7 +496,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': '이날 방문함', 'tours.walk.visit.isSkipped': '이날 건너뜀', 'tours.walk.visit.fromDay': '계획의 날짜에서 열면 그날의 정류지를 체크할 수 있습니다.', - 'tours.walk.visit.failed': '정류지를 표시하지 못했습니다. 연결이 필요하니 잠시 후 다시 시도하세요.', + 'tours.walk.visit.failed': '정류지를 표시하지 못했습니다. 산책을 다시 열고 한 번 더 시도하세요.', + 'tours.walk.visit.notSynced': + '이 산책은 오프라인 상태에서 이 날에 추가되었습니다. 동기화되면 정류지를 체크할 수 있습니다.', 'tours.walk.themes': '산책 테마', 'tours.walk.theme.culture': '문화와 역사', 'tours.walk.theme.parks': '공원과 전망', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 68603c4cd..b329bfb88 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -509,6 +509,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Berekend', 'tours.walk.stations': 'Haltes van de wandeling', 'tours.walk.stationNumbered': 'Halte {number}', + 'tours.walk.stationsUnread': + 'De haltes van deze wandeling konden niet worden geladen. Open hem opnieuw met verbinding om ze te zien en af te vinken.', 'tours.walk.progress': '{visited} van {total} bezocht', 'tours.walk.visit.visited': 'Bezocht', 'tours.walk.visit.skip': 'Overslaan', @@ -516,8 +518,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Op deze dag bezocht', 'tours.walk.visit.isSkipped': 'Op deze dag overgeslagen', 'tours.walk.visit.fromDay': 'Open de wandeling vanuit een dag in het plan om de haltes voor die dag af te vinken.', - 'tours.walk.visit.failed': - 'De halte kon niet worden gemarkeerd. Daar is verbinding voor nodig; probeer het zo opnieuw.', + 'tours.walk.visit.failed': 'De halte kon niet worden gemarkeerd. Open de wandeling opnieuw en probeer het nog eens.', + 'tours.walk.visit.notSynced': + 'Deze wandeling is offline aan de dag toegevoegd. De haltes kun je afvinken zodra ze gesynchroniseerd is.', 'tours.walk.themes': 'Thema van de wandeling', 'tours.walk.theme.culture': 'Cultuur en geschiedenis', 'tours.walk.theme.parks': 'Parken en uitzicht', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index b8be08c27..07d06727f 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -514,6 +514,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Obliczono', 'tours.walk.stations': 'Przystanki trasy', 'tours.walk.stationNumbered': 'Przystanek {number}', + 'tours.walk.stationsUnread': + 'Nie udało się wczytać przystanków tej trasy. Otwórz ją ponownie z połączeniem, aby je zobaczyć i odhaczyć.', 'tours.walk.progress': 'Odwiedzono {visited} z {total}', 'tours.walk.visit.visited': 'Odwiedzony', 'tours.walk.visit.skip': 'Pomiń', @@ -521,7 +523,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Odwiedzony tego dnia', 'tours.walk.visit.isSkipped': 'Pominięty tego dnia', 'tours.walk.visit.fromDay': 'Otwórz trasę z dnia w planie, aby odhaczyć przystanki na ten dzień.', - 'tours.walk.visit.failed': 'Nie udało się oznaczyć przystanku. Potrzebne jest połączenie; spróbuj za chwilę.', + 'tours.walk.visit.failed': 'Nie udało się oznaczyć przystanku. Otwórz trasę ponownie i spróbuj jeszcze raz.', + 'tours.walk.visit.notSynced': + 'Ta trasa została dodana do dnia offline. Przystanki będzie można odhaczyć, gdy się zsynchronizuje.', 'tours.walk.themes': 'Motyw trasy', 'tours.walk.theme.culture': 'Kultura i historia', 'tours.walk.theme.parks': 'Parki i widoki', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index cd45e001d..9589226e1 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -517,6 +517,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Расчёт', 'tours.walk.stations': 'Остановки прогулки', 'tours.walk.stationNumbered': 'Остановка {number}', + 'tours.walk.stationsUnread': + 'Не удалось загрузить остановки этой прогулки. Открой её снова при подключении к сети, чтобы увидеть и отметить их.', 'tours.walk.progress': 'Посещено {visited} из {total}', 'tours.walk.visit.visited': 'Посещено', 'tours.walk.visit.skip': 'Пропустить', @@ -524,7 +526,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Посещена в этот день', 'tours.walk.visit.isSkipped': 'Пропущена в этот день', 'tours.walk.visit.fromDay': 'Открой прогулку из дня в плане, чтобы отмечать остановки за этот день.', - 'tours.walk.visit.failed': 'Не удалось отметить остановку. Нужна связь; попробуй чуть позже.', + 'tours.walk.visit.failed': 'Не удалось отметить остановку. Открой прогулку заново и попробуй ещё раз.', + 'tours.walk.visit.notSynced': + 'Эта прогулка добавлена в день без сети. Остановки можно будет отмечать, когда она синхронизируется.', 'tours.walk.themes': 'Тема прогулки', 'tours.walk.theme.culture': 'Культура и история', 'tours.walk.theme.parks': 'Парки и виды', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 792c94a16..b908ec33b 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -499,6 +499,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Vypočítané', 'tours.walk.stations': 'Zastávky prechádzky', 'tours.walk.stationNumbered': 'Zastávka {number}', + 'tours.walk.stationsUnread': + 'Zastávky tejto prechádzky sa nepodarilo načítať. Otvor ju znova s pripojením, potom ich uvidíš a pôjde ich odškrtnúť.', 'tours.walk.progress': 'Navštívené {visited} z {total}', 'tours.walk.visit.visited': 'Navštívené', 'tours.walk.visit.skip': 'Preskočiť', @@ -506,7 +508,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Navštívené v tento deň', 'tours.walk.visit.isSkipped': 'Preskočené v tento deň', 'tours.walk.visit.fromDay': 'Otvor prechádzku z dňa v pláne, aby si odškrtol zastávky na ten deň.', - 'tours.walk.visit.failed': 'Zastávku sa nepodarilo označiť. Je potrebné pripojenie; skús to o chvíľu znova.', + 'tours.walk.visit.failed': 'Zastávku sa nepodarilo označiť. Otvor prechádzku znova a skús to ešte raz.', + 'tours.walk.visit.notSynced': + 'Táto prechádzka bola pridaná k dňu offline. Zastávky pôjde odškrtnúť, keď sa zosynchronizuje.', 'tours.walk.themes': 'Téma prechádzky', 'tours.walk.theme.culture': 'Kultúra a história', 'tours.walk.theme.parks': 'Parky a výhľady', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index aa977151e..a3a610c60 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -498,6 +498,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Beräknad', 'tours.walk.stations': 'Promenadens stopp', 'tours.walk.stationNumbered': 'Stopp {number}', + 'tours.walk.stationsUnread': + 'Promenadens stopp kunde inte läsas in. Öppna den igen med uppkoppling för att se dem och bocka av dem.', 'tours.walk.progress': '{visited} av {total} besökta', 'tours.walk.visit.visited': 'Besökt', 'tours.walk.visit.skip': 'Hoppa över', @@ -505,7 +507,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Besökt den här dagen', 'tours.walk.visit.isSkipped': 'Överhoppad den här dagen', 'tours.walk.visit.fromDay': 'Öppna promenaden från en dag i planen för att bocka av stoppen den dagen.', - 'tours.walk.visit.failed': 'Stoppet kunde inte markeras. Det kräver en anslutning; försök igen om en stund.', + 'tours.walk.visit.failed': 'Stoppet kunde inte markeras. Öppna promenaden igen och försök en gång till.', + 'tours.walk.visit.notSynced': + 'Den här promenaden lades till dagen offline. Stoppen kan bockas av när den har synkats.', 'tours.walk.themes': 'Promenadens tema', 'tours.walk.theme.culture': 'Kultur och historia', 'tours.walk.theme.parks': 'Parker och utsikter', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 7c2606812..72b847118 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -481,6 +481,7 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'ค่าคำนวณ', 'tours.walk.stations': 'จุดแวะของทริป', 'tours.walk.stationNumbered': 'จุดแวะที่ {number}', + 'tours.walk.stationsUnread': 'โหลดจุดแวะของทริปนี้ไม่ได้ เปิดทริปอีกครั้งขณะออนไลน์เพื่อดูและติ๊กจุดแวะ', 'tours.walk.progress': 'แวะแล้ว {visited} จาก {total}', 'tours.walk.visit.visited': 'แวะแล้ว', 'tours.walk.visit.skip': 'ข้าม', @@ -488,7 +489,8 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'แวะแล้วในวันนี้', 'tours.walk.visit.isSkipped': 'ข้ามไปในวันนี้', 'tours.walk.visit.fromDay': 'เปิดทริปจากวันในแผนเพื่อติ๊กจุดแวะของวันนั้น', - 'tours.walk.visit.failed': 'ทำเครื่องหมายจุดแวะไม่ได้ ต้องมีการเชื่อมต่อ ลองใหม่อีกครั้งในอีกสักครู่', + 'tours.walk.visit.failed': 'ทำเครื่องหมายจุดแวะไม่ได้ เปิดทริปอีกครั้งแล้วลองใหม่', + 'tours.walk.visit.notSynced': 'ทริปนี้ถูกเพิ่มลงในวันนั้นขณะออฟไลน์ ติ๊กจุดแวะได้เมื่อซิงค์เสร็จแล้ว', 'tours.walk.themes': 'ธีมของทริป', 'tours.walk.theme.culture': 'วัฒนธรรมและประวัติศาสตร์', 'tours.walk.theme.parks': 'สวนและจุดชมวิว', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 8d25b58a8..6f620e710 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 769; +const GENERAL_KEY_COUNT = 771; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index a2340a764..ca7842a8a 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -501,6 +501,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Hesaplanan', 'tours.walk.stations': 'Yürüyüşün durakları', 'tours.walk.stationNumbered': 'Durak {number}', + 'tours.walk.stationsUnread': + 'Bu yürüyüşün durakları yüklenemedi. Görmek ve işaretlemek için yürüyüşü bağlantı varken yeniden aç.', 'tours.walk.progress': '{total} duraktan {visited} ziyaret edildi', 'tours.walk.visit.visited': 'Ziyaret edildi', 'tours.walk.visit.skip': 'Atla', @@ -508,7 +510,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Bu gün ziyaret edildi', 'tours.walk.visit.isSkipped': 'Bu gün atlandı', 'tours.walk.visit.fromDay': 'Duraklarını o gün için işaretlemek için yürüyüşü plandaki bir günden aç.', - 'tours.walk.visit.failed': 'Durak işaretlenemedi. Bunun için bağlantı gerekir; birazdan yeniden dene.', + 'tours.walk.visit.failed': 'Durak işaretlenemedi. Yürüyüşü yeniden aç ve bir kez daha dene.', + 'tours.walk.visit.notSynced': + 'Bu yürüyüş güne çevrimdışıyken eklendi. Durakları eşitlendikten sonra işaretlenebilir.', 'tours.walk.themes': 'Yürüyüş teması', 'tours.walk.theme.culture': 'Kültür ve tarih', 'tours.walk.theme.parks': 'Parklar ve manzaralar', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 0fd49bf23..087933852 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -513,6 +513,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Розрахунок', 'tours.walk.stations': 'Зупинки прогулянки', 'tours.walk.stationNumbered': 'Зупинка {number}', + 'tours.walk.stationsUnread': + 'Не вдалося завантажити зупинки цієї прогулянки. Відкрий її знову з мережею, щоб побачити й відмітити їх.', 'tours.walk.progress': 'Відвідано {visited} з {total}', 'tours.walk.visit.visited': 'Відвідано', 'tours.walk.visit.skip': 'Пропустити', @@ -520,7 +522,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Відвідано цього дня', 'tours.walk.visit.isSkipped': 'Пропущено цього дня', 'tours.walk.visit.fromDay': 'Відкрий прогулянку з дня в плані, щоб відмічати зупинки на цей день.', - 'tours.walk.visit.failed': "Не вдалося позначити зупинку. Потрібне з'єднання; спробуй трохи згодом.", + 'tours.walk.visit.failed': 'Не вдалося позначити зупинку. Відкрий прогулянку знову й спробуй ще раз.', + 'tours.walk.visit.notSynced': + 'Цю прогулянку додано до дня без мережі. Зупинки можна буде відмічати, щойно вона синхронізується.', 'tours.walk.themes': 'Тема прогулянки', 'tours.walk.theme.culture': 'Культура та історія', 'tours.walk.theme.parks': 'Парки та краєвиди', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 754d6ccde..e43bd4243 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -498,6 +498,8 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Đã tính', 'tours.walk.stations': 'Các điểm dừng', 'tours.walk.stationNumbered': 'Điểm dừng {number}', + 'tours.walk.stationsUnread': + 'Không tải được các điểm dừng của lộ trình này. Hãy mở lại khi có kết nối để xem và đánh dấu chúng.', 'tours.walk.progress': 'Đã ghé {visited}/{total}', 'tours.walk.visit.visited': 'Đã ghé', 'tours.walk.visit.skip': 'Bỏ qua', @@ -505,7 +507,9 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': 'Đã ghé trong ngày này', 'tours.walk.visit.isSkipped': 'Đã bỏ qua trong ngày này', 'tours.walk.visit.fromDay': 'Mở lộ trình từ một ngày trong kế hoạch để đánh dấu các điểm dừng của ngày đó.', - 'tours.walk.visit.failed': 'Không thể đánh dấu điểm dừng. Cần có kết nối; hãy thử lại sau giây lát.', + 'tours.walk.visit.failed': 'Không thể đánh dấu điểm dừng. Hãy mở lại lộ trình và thử thêm lần nữa.', + 'tours.walk.visit.notSynced': + 'Lộ trình này được thêm vào ngày khi đang ngoại tuyến. Có thể đánh dấu các điểm dừng sau khi đã đồng bộ.', 'tours.walk.themes': 'Chủ đề lộ trình', 'tours.walk.theme.culture': 'Văn hóa và lịch sử', 'tours.walk.theme.parks': 'Công viên và cảnh đẹp', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 074b05c5e..6efc4754e 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -466,6 +466,7 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '計算值', 'tours.walk.stations': '漫步站點', 'tours.walk.stationNumbered': '第 {number} 站', + 'tours.walk.stationsUnread': '無法載入這次漫步的站點。請在連網時重新開啟,即可查看並勾選。', 'tours.walk.progress': '已到訪 {visited}/{total}', 'tours.walk.visit.visited': '已到訪', 'tours.walk.visit.skip': '略過', @@ -473,7 +474,8 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': '當天已到訪', 'tours.walk.visit.isSkipped': '當天已略過', 'tours.walk.visit.fromDay': '從計畫中的某一天打開漫步,即可勾選當天的站點。', - 'tours.walk.visit.failed': '無法標記該站點。需要網路連線,請稍後再試。', + 'tours.walk.visit.failed': '無法標記該站點。請重新開啟漫步再試一次。', + 'tours.walk.visit.notSynced': '這次漫步是在離線時加入當天的,同步完成後即可勾選站點。', 'tours.walk.themes': '漫步主題', 'tours.walk.theme.culture': '文化與歷史', 'tours.walk.theme.parks': '公園與景觀', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 4ef6e0880..a3fdf2da3 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -467,6 +467,7 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '计算值', 'tours.walk.stations': '漫步站点', 'tours.walk.stationNumbered': '第 {number} 站', + 'tours.walk.stationsUnread': '无法加载这次漫步的站点。请在联网时重新打开,即可查看并勾选。', 'tours.walk.progress': '已到访 {visited}/{total}', 'tours.walk.visit.visited': '已到访', 'tours.walk.visit.skip': '跳过', @@ -474,7 +475,8 @@ const tours: TranslationStrings = { 'tours.walk.visit.isVisited': '当天已到访', 'tours.walk.visit.isSkipped': '当天已跳过', 'tours.walk.visit.fromDay': '从计划中的某一天打开漫步,即可勾选当天的站点。', - 'tours.walk.visit.failed': '无法标记该站点。需要网络连接,请稍后再试。', + 'tours.walk.visit.failed': '无法标记该站点。请重新打开漫步再试一次。', + 'tours.walk.visit.notSynced': '这次漫步是在离线时加入当天的,同步完成后即可勾选站点。', 'tours.walk.themes': '漫步主题', 'tours.walk.theme.culture': '文化与历史', 'tours.walk.theme.parks': '公园与景观', diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index ddd346899..038b4f6f4 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -57,7 +57,7 @@ TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for On login, when the browser comes back online, and when you lift **Force offline mode**, TREK runs a background sync that writes full trip bundles into IndexedDB — you can also start one by hand with **Re-sync now**, with **Download for offline use** for a progress-tracked run, or by re-enabling a trip's offline toggle: - Trips, days, places, packing items, to-dos, budget items, reservations, accommodations, trip members, tags, and categories. -- With the Tours addon on, each trip's tours, so a tour still reads as a tour offline. A tour you opened in the route editor while online keeps its control points, so it opens there offline too. +- With the Tours addon on, each trip's tours, so a tour still reads as a tour offline. A tour you opened in the route editor while online keeps its control points, so it opens there offline too. For a city walk the sync also stores the stops checked off on each of its days, and the walk's stops themselves when this device has not read them yet, so the walk shows how far it got and can be checked off offline. - File attachments that are neither photos nor videos (PDFs, documents, etc.) are downloaded and stored as blobs in IndexedDB. Videos are deliberately skipped — a single clip can be hundreds of megabytes and would evict the trip's real documents. - Map tiles are pre-fetched into the service-worker `map-tiles` cache for zoom levels 0 to 16 across each trip's bounding box, stopping at the zoom level that would push the total past 12 288 tiles (roughly 180 MB). If the browser refused persistent storage, prefetching stops at zoom 12 so the app shell cannot be evicted. - The places around each trip, up to 3000 from the [TREK Places API](TREK-Places-API) in one request (about a megabyte for a city), so place search and suggestions still answer offline. They are downloaded whether or not **Store map tiles offline** is on, refreshed only when the trip's area changes, and removed with the trip. See [Searching offline](Places-and-Search#searching-offline). @@ -95,13 +95,13 @@ If a change you made offline collides with a newer change on the server, it is s ### Stats & cache -The stats panel shows cached trips, pending changes, conflicts and failed changes. A change the server keeps refusing with a server error is retried with growing gaps (from 30 seconds up to about two hours) and only holds back the other changes of its own trip; after eight attempts it counts as failed. Later changes you make on this device to the same item (the same place, packing item, visit, tour or driving settings) wait behind a failed change or an open conflict instead of overtaking it, so they reach the server in the order you made them; for visits, tours and driving settings that includes changes you make while online. Places and packing items remember which version you edited, so trying a failed change again never overwrites a newer version saved in the meantime, on this device or by another member of the trip: it turns into a conflict for you to decide. Visits, tours and driving settings carry no such version, so trying again overwrites what another member changed on the same item in the meantime, the same as any change sent after being offline. Failed changes come with **Try again** and **Discard**: trying again sends them first and then the changes that waited behind them; discarding keeps the server's version, sends the changes that waited, and the next sync puts the server's version back on the device. **Clear cache** removes all offline data from IndexedDB after you confirm it in TREK's dialog (you can re-sync any time while online). Each cached trip entry shows its date range, place/file count and last successful sync. +The stats panel shows cached trips, pending changes, conflicts and failed changes. A change the server keeps refusing with a server error is retried with growing gaps (from 30 seconds up to about two hours) and only holds back the other changes of its own trip; after eight attempts it counts as failed. Later changes you make on this device to the same item (the same place, packing item, visit, tour, a city walk's stops on one day, or driving settings) wait behind a failed change or an open conflict instead of overtaking it, so they reach the server in the order you made them; for visits, tours, the stops of a city walk and driving settings that includes changes you make while online. Places and packing items remember which version you edited, so trying a failed change again never overwrites a newer version saved in the meantime, on this device or by another member of the trip: it turns into a conflict for you to decide. Visits, tours, the stops of a city walk and driving settings carry no such version, so trying again overwrites what another member changed on the same item in the meantime, the same as any change sent after being offline. Failed changes come with **Try again** and **Discard**: trying again sends them first and then the changes that waited behind them; discarding keeps the server's version, sends the changes that waited, and the next sync puts the server's version back on the device. **Clear cache** removes all offline data from IndexedDB after you confirm it in TREK's dialog (you can re-sync any time while online). Each cached trip entry shows its date range, place/file count and last successful sync. Changes waiting to sync remember which version of TREK queued them. After an update, a browser tab that still runs the previous version leaves changes queued by the newer version alone instead of sending them in a form it does not know: they stay under pending changes, together with the later changes to the same item, until a tab on the current version (reload the page) sends them. Nothing is lost. The same applies after an install is rolled back, but only to a release that already has this check; an older release sends such changes anyway. After a rollback no tab on the newer version comes back, so these changes stay under pending changes and keep holding back the later changes to the same item, including online changes to visits, tours and driving settings. They cannot be tried again or discarded one by one: **Clear cache** is the way out, and it also drops every other change that has not synced yet. ## Limitations -- Offline **editing** is supported for places and packing items (with conflict detection), plus a visit's start and end time, its **End the day here** flag and the road trip **Driving settings** (queued, without conflict detection). A tour can be saved or edited offline as well (queued, without conflict detection), but drawing a new route needs a connection, since routing and elevation come from the routing server, and the GPX import is an upload. Other entities — budget, to-dos, reservations, days — require connectivity to edit; while forced offline those edits still go to the live server when a connection is actually present. +- Offline **editing** is supported for places and packing items (with conflict detection), plus a visit's start and end time, its **End the day here** flag, the road trip **Driving settings** and checking off the stops of a city walk (queued, without conflict detection). A city walk put on a day while offline can be checked off once it has synced, and a stop checked off offline whose stop, day or walk was removed in the meantime is dropped when it syncs. A tour can be saved or edited offline as well (queued, without conflict detection), but drawing a new route needs a connection, since routing and elevation come from the routing server, and the GPX import is an upload. Other entities (budget, to-dos, reservations, days) require connectivity to edit; while forced offline those edits still go to the live server when a connection is actually present. - A change you made offline that **deletes** an item wins over a concurrent server edit of that same item ("delete wins"); only edit-vs-edit conflicts are surfaced for resolution. - The conflict token has one-second resolution, so two edits to the same field within the same second can't be told apart and fall back to last-write-wins (only relevant to sub-second races; normal offline windows are unaffected). - Creating a trip requires connectivity. Trip creation is not queued, so a new trip cannot be started while offline.