feat(journey): keep the route recorded on a journey day from Dawarich, previewed first, with its gaps

A day header in the journey timeline now carries the Dawarich mark for an
editor whose Dawarich answers (dimmed with the reason while offline). It opens
a dialog that names the journey and the day and reads that one local day from
the caller's own connection: a sketch of the route, distance, first and last
time, pieces, gaps without recording, modes and whether tracks or thinned
points answered. The day is cut in the zone Dawarich records in where its
answer shows one, else in this device's, and a picker cuts it in any other.
Nothing is kept before "Keep route". On a phone the same sheet opens from the
journey's More options for the day of the card shown.

What is kept is the preview that was seen: the save reads the day again and
refuses with recording_changed when its hash moved, and the dialog shows the
new version. The same day taken over twice keeps one version and announces
nothing; a different version is replaced only on "Replace", never touching an
entry or text. A failed read throws before any write, so the kept version
stays. Gaps stay gaps: points are split where silence ends elsewhere, and no
day is joined to the one before. The dialog says how many other people will
see the route; editors may keep their own recording.

Kept days show in the day header (distance, whose recording, since when,
still running) and on the desktop and phone journey maps in the colour of
their day's pins, winning over the live trail of their date, and stay drawn
without Dawarich. A trip's GPX track is now drawn dashed as the plan, recorded
routes solid. Shared links do not show recorded days. Remove route asks first
and works without a connection.

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
This commit is contained in:
Maurice
2026-10-21 15:05:21 +02:00
parent cbca3b2851
commit 65bb2d95eb
99 changed files with 6359 additions and 51 deletions
@@ -0,0 +1,61 @@
// FE-JRN-DAY-RECORDING-BTN-001 to FE-JRN-DAY-RECORDING-BTN-005
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { buildRecordedDay } from '../../../tests/helpers/journeyRecording';
import { render, screen } from '../../../tests/helpers/render';
import JourneyDayRecordingButton from './JourneyDayRecordingButton';
function renderButton(props: Partial<Parameters<typeof JourneyDayRecordingButton>[0]> = {}) {
const onOpen = vi.fn();
render(
<JourneyDayRecordingButton
day={undefined}
canEdit
canTakeOver={false}
needsConnection={false}
onOpen={onOpen}
{...props}
/>
);
return onOpen;
}
describe('JourneyDayRecordingButton', () => {
it('FE-JRN-DAY-RECORDING-BTN-001: a kept day shows how far it went and whose recording it is, and opens for an editor', async () => {
const onOpen = renderButton({ day: buildRecordedDay() });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveTextContent('1.5 km');
expect(button.getAttribute('aria-label')).toMatch(/1\.5 km, taken over by marta/);
await userEvent.click(button);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAY-RECORDING-BTN-002: a viewer sees the kept day but cannot open it, and a running day says so', async () => {
const onOpen = renderButton({ day: buildRecordedDay({ provisional: true, importedBy: null }), canEdit: false });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.getAttribute('aria-label')).toMatch(/taken over on .*The day was still running then\./);
await userEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it('FE-JRN-DAY-RECORDING-BTN-003: without a kept day an editor whose Dawarich answers may take it over', async () => {
const onOpen = renderButton({ canTakeOver: true });
await userEvent.click(screen.getByRole('button', { name: 'Take over recording' }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAY-RECORDING-BTN-004: offline the offer stays, dimmed, and says why', async () => {
const onOpen = renderButton({ needsConnection: true });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.getAttribute('aria-label')).toMatch(/connection/);
await userEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it('FE-JRN-DAY-RECORDING-BTN-005: nothing kept and nothing to offer draws nothing', () => {
renderButton();
expect(screen.queryByTestId('journey-day-recording')).toBeNull();
});
});
@@ -0,0 +1,86 @@
import type { JourneyRecordedDay } from '@trek/shared';
import { Route } from 'lucide-react';
import type React from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import DawarichIcon from '../shared/DawarichIcon';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
// aria-disabled rather than disabled: a disabled button gets no pointer events, and
// the tooltip is what tells a viewer or an offline editor what they are looking at.
const ROUND =
'grid h-7 w-7 place-items-center rounded-full transition-colors hover:bg-surface-hover aria-disabled:cursor-default aria-disabled:opacity-40';
const PILL =
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-bold tabular-nums transition-colors aria-disabled:cursor-default [&:not([aria-disabled=true])]:hover:bg-surface-hover';
/**
* The recorded route of one journey day in its header (JRN-01).
*
* A day that keeps its route shows how far it went, for everyone who can open
* the journey, with whose recording it is and since when in the tooltip; an
* editor opens it to check it against Dawarich again, replace it or drop it.
* A day without one offers an editor whose Dawarich answers to take it over;
* offline the offer stays, dimmed, and says why.
*/
export default function JourneyDayRecordingButton({
day,
canEdit,
canTakeOver,
needsConnection,
onOpen,
}: {
day: JourneyRecordedDay | undefined;
canEdit: boolean;
canTakeOver: boolean;
needsConnection: boolean;
onOpen: () => void;
}): React.ReactElement | null {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
if (day) {
const distance = formatDistance(day.stats.distanceMeters / 1000, unit);
const date = new Date(day.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
const kept = day.importedBy
? t('journey.recording.keptBy', { distance, name: day.importedBy.username, date })
: t('journey.recording.keptOn', { distance, date });
const label = day.provisional ? `${kept} ${t('journey.recording.keptRunning')}` : kept;
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={canEdit ? onOpen : undefined}
aria-disabled={!canEdit}
aria-label={label}
data-testid="journey-day-recording"
className={PILL}
style={{ ...fs(10.5), background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
>
<Route size={12} strokeWidth={2.4} aria-hidden="true" />
{distance}
{day.provisional && <span className="h-1.5 w-1.5 rounded-full bg-warning" aria-hidden="true" />}
</button>
</Tooltip>
);
}
if (!canTakeOver && !needsConnection) return null;
const label = canTakeOver ? t('journey.recording.takeOver') : t('dawarich.error.offline');
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={canTakeOver ? onOpen : undefined}
aria-disabled={!canTakeOver}
aria-label={label}
data-testid="journey-day-recording"
className={ROUND}
style={{ background: 'var(--vg-surf2)' }}
>
<DawarichIcon size={15} />
</button>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-051
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -623,8 +623,31 @@ describe('JourneyMap', () => {
expect(line).toBeDefined();
// Leaflet wants [lat, lng], which is the order the geometry is stored in.
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
// Solid, unlike the dashed line that merely connects entries in time order.
expect((line![1] as any).dashArray).toBeUndefined();
// A trip's GPX track is the plan: dashed, and wider apart than the faint
// line that merely connects entries in time order.
expect((line![1] as any).dashArray).toBe('8 7');
});
it('FE-COMP-JOURNEYMAP-051: a recorded route is drawn solid, apart from the planned one (JRN-01)', () => {
vi.mocked(L.polyline).mockClear();
render(
<JourneyMap
checkins={[]}
entries={entriesWithCoords}
tracks={[
{ ...track({ color: '#00aa00' }), kind: 'day_recording' as const },
{ ...track({ color: '#0000aa' }), kind: 'dawarich_live' as const },
{ ...track({ color: '#aa0000' }), kind: 'trip_gpx' as const },
]}
/>
);
const style = (color: string) =>
vi.mocked(L.polyline).mock.calls.map(c => c[1] as L.PolylineOptions | undefined).find(o => o?.color === color);
expect(style('#00aa00')).toBeDefined();
expect(style('#00aa00')?.dashArray).toBeUndefined();
expect(style('#0000aa')?.dashArray).toBeUndefined();
expect(style('#aa0000')?.dashArray).toBe('8 7');
});
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
+11 -4
View File
@@ -143,6 +143,8 @@ const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
const TRACK_FALLBACK_COLOR = '#4f46e5'
/** The dash of a planned track; wide enough not to pass for the faint line between entries. */
const PLANNED_DASH = '8 7'
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
@@ -312,15 +314,20 @@ function JourneyMap(
coords.forEach(c => allCoords.push(c))
}
// GPX tracks — drawn solid and in their own colour, so they read as a recorded
// route rather than as the dashed line that merely connects entries in time order.
// A white casing keeps them legible on satellite tiles, same as the planner map.
// Tracks, in their own colour over a white casing that keeps them legible on
// satellite tiles, same as the planner map. What was recorded (a day the journey
// keeps, the live Dawarich trail) is drawn solid; a trip's GPX track is the plan
// and drawn dashed, so the two never read as the same thing (JRN-01).
for (const track of stableTracks) {
if (track.points.length < 2) continue
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
const color = track.color || TRACK_FALLBACK_COLOR
const planned = !track.kind || track.kind === 'trip_gpx'
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map)
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' })
const line = L.polyline(coords, {
color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round',
...(planned ? { dashArray: PLANNED_DASH } : {}),
})
// Same tooltip the markers on this map use, rather than Leaflet's default box:
// it follows the appearance tokens, so it lands right in dark mode and with
// transparency switched off. Escaped because a string handed to bindTooltip
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026, FE-COMP-JMAPGL-2453
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-038, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn>
@@ -270,6 +270,34 @@ describe('JourneyMapGL', () => {
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
})
it('FE-COMP-JMAPGL-038: a planned GPX track goes on the dashed layer, a recorded route on the solid one (JRN-01)', () => {
withToken()
render(
<JourneyMapGL
checkins={[]}
entries={singleEntry}
tracks={[
{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] },
{ place_id: -100001, trip_id: 0, name: 'Recorded on 14 May', color: '#00aa00', points: [[47, 11], [47.2, 11.2]], kind: 'day_recording' },
]}
/>,
)
const source = vi.mocked(gl.map.addSource).mock.calls.find(c => c[0] === 'journey-tracks')?.[1] as
| { data: { features: Array<{ properties: { planned: boolean } }> } }
| undefined
expect(source?.data.features.map(f => f.properties.planned)).toEqual([true, false])
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
id: 'journey-tracks-planned',
filter: ['==', ['get', 'planned'], true],
paint: expect.objectContaining({ 'line-dasharray': [2.2, 2] }),
}))
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
id: 'journey-tracks-line',
filter: ['!=', ['get', 'planned'], true],
}))
})
it('FE-COMP-JMAPGL-009: a single entry draws no route line', () => {
withToken()
render(<JourneyMapGL checkins={[]} entries={singleEntry} />)
+19 -4
View File
@@ -357,9 +357,11 @@ function JourneyMapGL(
}
}
// GPX tracks — one source for all of them, coloured per feature so a journey
// with several recorded routes keeps them apart. Casing underneath, same as the
// planner map, so a track stays readable on satellite tiles.
// Tracks — one source for all of them, coloured per feature so a journey
// with several routes keeps them apart. Casing underneath, same as the
// planner map, so a track stays readable on satellite tiles. A trip's GPX
// track is the plan and goes on a dashed layer, what was recorded on a solid
// one (JRN-01): a dash pattern cannot follow a feature property.
if (stableTracks.length > 0) {
const featureCollection: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@@ -367,7 +369,11 @@ function JourneyMapGL(
.filter(track => track.points.length > 1)
.map(track => ({
type: 'Feature' as const,
properties: { color: track.color || TRACK_FALLBACK_COLOR, name: track.name },
properties: {
color: track.color || TRACK_FALLBACK_COLOR,
name: track.name,
planned: !track.kind || track.kind === 'trip_gpx',
},
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
})),
}
@@ -387,9 +393,18 @@ function JourneyMapGL(
id: 'journey-tracks-line',
type: 'line',
source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'journey-tracks-planned',
type: 'line',
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95, 'line-dasharray': [2.2, 2] },
layout: { 'line-cap': 'butt', 'line-join': 'round' },
})
}
}
}
@@ -0,0 +1,103 @@
// FE-JRN-RECORDING-DIALOG-001 to FE-JRN-RECORDING-DIALOG-005
//
// JRN-01: the desktop dialog that takes one day's recorded route over. It names
// the journey and the day, shows what would be kept, and keeps it only on its
// button.
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import JourneyRecordingDialog from './JourneyRecordingDialog';
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
function renderDialog(stored?: ReturnType<typeof buildRecordedDay>) {
const props = { onKept: vi.fn(), onRemoved: vi.fn(), onClose: vi.fn() };
render(<JourneyRecordingDialog journeyId={7} journeyTitle="Venice" date="2026-05-14" stored={stored} {...props} />);
return props;
}
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
});
describe('JourneyRecordingDialog', () => {
it('FE-JRN-RECORDING-DIALOG-001: names the journey and the day and shows what the day covers before anything is kept', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview({ audience: 2 }));
renderDialog();
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText(/May 14, 2026/)).toBeInTheDocument();
expect(screen.getByText('1.5 km')).toBeInTheDocument();
expect(screen.getByText('1 piece')).toBeInTheDocument();
expect(screen.getByText('Walking')).toBeInTheDocument();
expect(screen.getByText(/2 other people on this journey will see the route/)).toBeInTheDocument();
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DIALOG-002: keeps the day on its button and closes', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const day = buildRecordedDay();
repo.save.mockResolvedValue({ day, outcome: 'created' });
const props = renderDialog();
await userEvent.click(await screen.findByRole('button', { name: 'Keep route' }));
await waitFor(() => expect(props.onKept).toHaveBeenCalledWith(day));
expect(props.onClose).toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DIALOG-003: gaps and an unfinished day are said out loud, a kept twin cannot be kept again', async () => {
const preview = buildRecordingPreview();
repo.preview.mockResolvedValue({
...preview,
provisional: true,
stats: { ...preview.stats, gapCount: 2 },
stored: buildRecordedDay(),
sameAsStored: true,
});
renderDialog(buildRecordedDay());
expect(await screen.findByText('2 gaps')).toBeInTheDocument();
expect(screen.getByText(/not over yet in this time zone/)).toBeInTheDocument();
expect(screen.getByText('This journey keeps exactly this recording already.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Already kept' })).toBeDisabled();
});
it('FE-JRN-RECORDING-DIALOG-004: when Dawarich cannot be read it says why, that the kept route stays, and offers to try again', async () => {
repo.preview.mockRejectedValueOnce(httpError('unauthorized'));
renderDialog(buildRecordedDay());
expect(await screen.findByText(/Dawarich rejected the API key\./)).toBeInTheDocument();
expect(screen.getByText(/The route this journey keeps stays as it is\./)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep route' })).toBeDisabled();
repo.preview.mockResolvedValue(buildRecordingPreview());
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
});
it('FE-JRN-RECORDING-DIALOG-005: removing the kept route asks first, also when Dawarich does not answer', async () => {
repo.preview.mockRejectedValue(httpError('not_connected'));
repo.remove.mockResolvedValue(undefined);
const props = renderDialog(buildRecordedDay());
await screen.findByText(/No Dawarich instance is connected yet\./);
await userEvent.click(screen.getByRole('button', { name: 'Remove route' }));
expect(repo.remove).not.toHaveBeenCalled();
expect(await screen.findByText('Remove the kept route?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove route' });
const confirm = buttons[buttons.length - 1];
if (!confirm) throw new Error('no confirm button');
await userEvent.click(confirm);
await waitFor(() => expect(props.onRemoved).toHaveBeenCalledWith('2026-05-14'));
});
});
@@ -0,0 +1,61 @@
import { useId } from 'react';
import { useTranslation } from '../../i18n';
import DawarichIcon from '../shared/DawarichIcon';
import { DialogFooter, DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell';
import { JourneyRecordingActions, JourneyRecordingReview } from './JourneyRecordingReview';
import { useJourneyRecordingDay, type JourneyRecordingViewProps } from './useJourneyRecordingDay';
/**
* Taking one day's recorded route from Dawarich into the journey (JRN-01),
* on the desktop: the journey and the day in the head band, the preview in the
* body, keeping it only on the button that says so.
*/
export default function JourneyRecordingDialog({
journeyTitle,
date,
stored,
onClose,
...rest
}: JourneyRecordingViewProps) {
const { t, locale } = useTranslation();
const labelId = useId();
const day = useJourneyRecordingDay({ date, onClose, ...rest });
const title = new Date(`${date}T00:00:00`).toLocaleDateString(locale, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
header={
<DialogHeader
tile={
<DialogTile>
<DawarichIcon size={26} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={day.preview?.journey.title ?? journeyTitle}
title={title}
sub={t('journey.recording.sub')}
subWraps
/>
}
footer={
<DialogFooter>
<JourneyRecordingActions day={day} stored={stored} onCancel={onClose} />
</DialogFooter>
}
>
<JourneyRecordingReview day={day} stored={stored} />
</DialogShell>
);
}
@@ -0,0 +1,286 @@
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
import { AlertTriangle, Clock, Info, Loader2, RefreshCw, Route, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { PANEL } from '../shared/dialogParts';
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { modeLabel, sketchPaths } from './journeyRecordingModel';
import type { useJourneyRecordingDay } from './useJourneyRecordingDay';
type RecordingDayState = ReturnType<typeof useJourneyRecordingDay>;
const SKETCH_W = 320;
const SKETCH_H = 150;
/** A sentence about the recording, its icon saying how much it matters. */
function Notice({ tone, children }: { tone: 'info' | 'warning' | 'danger'; children: ReactNode }) {
const look = {
info: 'bg-surface-secondary text-content-secondary',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
}[tone];
const Icon = tone === 'info' ? Info : AlertTriangle;
return (
<p className={`m-0 flex items-start gap-2 rounded-[12px] px-3 py-2.5 ${look}`} style={fs(12.5, 'body')}>
<Icon size={15} strokeWidth={2} className="mt-px flex-none" aria-hidden="true" />
<span className="min-w-0">{children}</span>
</p>
);
}
/** The day's pieces drawn into a box, each its own line, so a gap stays a gap. */
function RecordingSketch({ segments }: { segments: JourneyRecordingPreview['segments'] }) {
const { paths, ends } = sketchPaths(segments, SKETCH_W, SKETCH_H);
return (
<svg
viewBox={`0 0 ${SKETCH_W} ${SKETCH_H}`}
className="block h-[150px] w-full"
role="img"
aria-hidden="true"
data-testid="recording-sketch"
>
{paths.map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke="var(--accent)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{ends.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={3} fill="var(--bg-card)" stroke="var(--text-faint)" strokeWidth={1.5} />
))}
</svg>
);
}
/** A time of the recording on the clock of the zone the day is cut in. */
function clockIn(iso: string, zoneMinutes: number, locale: string, is12h: boolean): string {
return new Date(Date.parse(iso) + zoneMinutes * 60_000).toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
hour12: is12h,
});
}
/** The day in figures: the sketch, how far, when, in how many pieces and gaps, how and from what. */
function RecordingFacts({ preview }: { preview: JourneyRecordingPreview }) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const { stats } = preview;
return (
<div className={PANEL}>
<RecordingSketch segments={preview.segments} />
<div className="flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Route size={11} aria-hidden="true" />}>
{formatDistance(stats.distanceMeters / 1000, unit)}
</SoftPill>
{stats.firstAt && stats.lastAt && (
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
{t('journey.recording.span', {
from: clockIn(stats.firstAt, preview.utcOffsetMinutes, locale, is12h),
to: clockIn(stats.lastAt, preview.utcOffsetMinutes, locale, is12h),
})}
</SoftPill>
)}
<SoftPill>{t('journey.recording.pieces', { count: stats.segmentCount })}</SoftPill>
{stats.gapCount > 0 && (
<Tooltip label={t('journey.recording.gapsHint')} placement="top">
<span tabIndex={0} className="inline-flex">
<SoftPill tone="info">{t('journey.recording.gaps', { count: stats.gapCount })}</SoftPill>
</span>
</Tooltip>
)}
{stats.modes.map((mode) => (
<SoftPill key={mode}>{modeLabel(mode, t)}</SoftPill>
))}
<Tooltip label={t('journey.recording.sourceHint')} placement="top">
<span tabIndex={0} className="inline-flex">
<SoftPill>
{t(preview.sourceKind === 'tracks' ? 'journey.recording.sourceTracks' : 'journey.recording.sourcePoints')}
</SoftPill>
</span>
</Tooltip>
</div>
</div>
);
}
/** Every reason to look twice before keeping the day, and who will see it. */
function RecordingNotices({
preview,
changedSincePreview,
}: {
preview: JourneyRecordingPreview;
changedSincePreview: boolean;
}) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const { stored } = preview;
return (
<>
{changedSincePreview && <Notice tone="warning">{t('journey.recording.changedSincePreview')}</Notice>}
{preview.truncated && <Notice tone="warning">{t('journey.recording.truncated')}</Notice>}
{preview.provisional && <Notice tone="warning">{t('journey.recording.provisional')}</Notice>}
{preview.sameAsStored && <Notice tone="info">{t('journey.recording.same')}</Notice>}
{stored && !preview.sameAsStored && (
<Notice tone="info">
{t('journey.recording.differs', {
date: new Date(stored.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
distance: formatDistance(stored.stats.distanceMeters / 1000, unit),
})}
</Notice>
)}
{preview.audience > 0 && (
<p className="m-0 flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
<Users size={14} className="mt-px flex-none" aria-hidden="true" />
{t('journey.recording.audience', { count: preview.audience })}
</p>
)}
</>
);
}
/** Reading, failed, nothing recorded, or the day to look at. */
function RecordingState({ day, stored }: { day: RecordingDayState; stored: JourneyRecordedDay | undefined }) {
const { t } = useTranslation();
const { preview } = day;
if (day.loading) {
return (
<p className="m-0 flex items-center gap-2 py-6 text-content-muted" role="status" style={fs(12.5, 'body')}>
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
{t('journey.recording.loading')}
</p>
);
}
if (day.error) {
return (
<div className="flex flex-col gap-2">
<Notice tone="danger">
{day.error}
{stored && ` ${t('journey.recording.keptStays')}`}
</Notice>
<span>
<DialogButton onClick={day.retry} icon={<RefreshCw size={14} aria-hidden="true" />}>
{t('journey.recording.retry')}
</DialogButton>
</span>
</div>
);
}
if (!preview) return null;
if (preview.contentHash === null) return <Notice tone="info">{t('journey.recording.empty')}</Notice>;
return (
<div className="flex flex-col gap-3">
<RecordingFacts preview={preview} />
<RecordingNotices preview={preview} changedSincePreview={day.changedSincePreview} />
</div>
);
}
/**
* What taking a day over would keep (JRN-01): the zone the day is cut in, the
* recording as a sketch and in figures, and every reason to look twice. The
* body of the desktop dialog and of the phone sheet alike.
*/
export function JourneyRecordingReview({
day,
stored,
}: {
day: RecordingDayState;
/** What the journey keeps for the day, known even when Dawarich does not answer. */
stored: JourneyRecordedDay | undefined;
}) {
const { t } = useTranslation();
return (
<>
<DialogSection label={t('journey.recording.zone')}>
<CustomSelect
ariaLabel={t('journey.recording.zone')}
value={day.zone}
onChange={(value) => day.chooseZone(Number(value))}
options={day.zoneOptions}
disabled={day.loading}
/>
<p className="m-0 mt-1.5 text-content-faint" style={fs(11.5)}>
{t('journey.recording.zoneHint')}
</p>
</DialogSection>
<DialogSection label={t('journey.recording.scope')}>
<RecordingState day={day} stored={stored ?? day.preview?.stored ?? undefined} />
</DialogSection>
</>
);
}
/**
* The buttons under the review: drop the kept route (asked first), close, and
* the one that keeps, replaces or says the day is kept already.
*/
export function JourneyRecordingActions({
day,
stored,
onCancel,
}: {
day: RecordingDayState;
stored: JourneyRecordedDay | undefined;
onCancel: () => void;
}) {
const { action, busy } = day;
const hasStored = Boolean(stored ?? day.preview?.stored);
const onSave = () => void day.save();
const onRemove = () => void day.remove();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const primary =
action === 'replace'
? t('journey.recording.replace')
: action === 'same'
? t('journey.recording.alreadyKept')
: t('journey.recording.keep');
const canSave = (action === 'keep' || action === 'replace') && busy === null;
return (
<>
{hasStored && (
<DeleteButton
onClick={() => setConfirming(true)}
label={t('journey.recording.remove')}
disabled={busy !== null}
/>
)}
<FooterSpacer />
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={onSave}
disabled={!canSave}
icon={busy === 'save' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
>
{primary}
</DialogButton>
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
onRemove();
}}
title={t('journey.recording.removeTitle')}
message={t('journey.recording.removeMessage')}
confirmLabel={t('journey.recording.remove')}
/>
</>
);
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import {
deviceOffsetOn,
formatOffset,
modeLabel,
recordingErrorCode,
recordingErrorText,
sketchPaths,
zoneOptions,
} from './journeyRecordingModel';
// FE-JRN-RECORDING-MODEL-001 to FE-JRN-RECORDING-MODEL-006
const t = (key: string) => `t:${key}`;
const httpError = (code: string) => ({ response: { data: { error: 'English', code } } });
describe('journeyRecordingModel', () => {
it('FE-JRN-RECORDING-MODEL-001: spells an offset the way a zone is named', () => {
expect(formatOffset(120)).toBe('UTC+02:00');
expect(formatOffset(-210)).toBe('UTC-03:30');
expect(formatOffset(0)).toBe('UTC+00:00');
expect(formatOffset(345)).toBe('UTC+05:45');
expect(deviceOffsetOn('2026-05-14')).toBe(-new Date('2026-05-14T12:00:00').getTimezoneOffset());
});
it('FE-JRN-RECORDING-MODEL-002: lists every zone once, marking the one of Dawarich and the one of this device', () => {
const options = zoneOptions(t, 120, 60);
expect(options[0]).toEqual({ value: -720, label: 'UTC-12:00' });
expect(options[options.length - 1]).toEqual({ value: 840, label: 'UTC+14:00' });
expect(options.find((o) => o.value === 120)).toEqual({
value: 120,
label: 'UTC+02:00',
badge: 't:journey.recording.zoneSource',
});
expect(options.find((o) => o.value === 60)).toEqual({
value: 60,
label: 'UTC+01:00',
badge: 't:journey.recording.zoneDevice',
});
expect(new Set(options.map((o) => o.value)).size).toBe(options.length);
// An odd zone a device really is in still gets its row.
expect(zoneOptions(t, null, 17).some((o) => o.value === 17)).toBe(true);
});
it('FE-JRN-RECORDING-MODEL-003: names the modes TREK knows and shows any other as Dawarich spells it', () => {
expect(modeLabel('walking', t)).toBe('t:journey.recording.mode.walking');
expect(modeLabel('hovercraft', t)).toBe('hovercraft');
});
it('FE-JRN-RECORDING-MODEL-004: says a refusal in the reader’s language, from the code the server sent', () => {
expect(recordingErrorText(new OnlineOnlyError(), t)).toBe('t:dawarich.error.offline');
expect(recordingErrorText(httpError('recording_exists'), t)).toBe('t:journey.recording.error.recording_exists');
expect(recordingErrorCode(httpError('recording_changed'))).toBe('recording_changed');
const echo = (key: string) => (key === 'dawarich.error.not_connected' ? 'Not connected' : key);
expect(recordingErrorText(httpError('not_connected'), echo)).toBe('Not connected');
expect(recordingErrorText(httpError('brand_new'), echo)).toBe('dawarich.error.unknown');
expect(recordingErrorText(new Error('boom'), echo)).toBe('dawarich.error.unknown');
expect(recordingErrorCode(new Error('boom'))).toBeNull();
});
it('FE-JRN-RECORDING-MODEL-005: draws each piece on its own, north up, inside the box', () => {
const { paths, ends } = sketchPaths(
[
{
points: [
[45, 12],
[45.01, 12.01],
],
},
{
points: [
[45.05, 12.05],
[45.06, 12.07],
],
},
],
320,
150
);
expect(paths).toHaveLength(2);
expect(paths.every((d) => d.startsWith('M') && !d.includes('M', 1))).toBe(true);
expect(ends).toHaveLength(4);
for (const [x, y] of ends) {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(320);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(150);
}
// The northernmost point sits higher in the box than the southernmost.
expect(ends[3]?.[1]).toBeLessThan(ends[0]?.[1] ?? 0);
});
it('FE-JRN-RECORDING-MODEL-006: nothing recorded is nothing drawn, and a lone fix is no line', () => {
expect(sketchPaths([], 320, 150)).toEqual({ paths: [], ends: [] });
expect(sketchPaths([{ points: [[45, 12]] }], 320, 150)).toEqual({ paths: [], ends: [] });
});
});
@@ -0,0 +1,154 @@
import { OnlineOnlyError } from '../../repo/onlineOnly';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* The zones a day can be cut in: every whole hour from UTC-12 to UTC+14 and
* the half and three-quarter hours places actually keep (India, Nepal,
* Newfoundland, Iran, parts of Australia, the Chathams).
*/
const ZONE_OFFSETS = [
...Array.from({ length: 27 }, (_, i) => (i - 12) * 60),
-570,
-210,
210,
270,
330,
345,
390,
525,
570,
630,
765,
].sort((a, b) => a - b);
/** `UTC+05:30`, `UTC-03:00`, `UTC+00:00`. */
export function formatOffset(minutes: number): string {
const sign = minutes < 0 ? '-' : '+';
const abs = Math.abs(minutes);
const hours = String(Math.floor(abs / 60)).padStart(2, '0');
const rest = String(abs % 60).padStart(2, '0');
return `UTC${sign}${hours}:${rest}`;
}
/** The zone this device is in on that day, so a day abroad in summer gets its summer offset. */
export function deviceOffsetOn(date: string): number {
return -new Date(`${date}T12:00:00`).getTimezoneOffset();
}
interface ZoneOption {
value: number;
label: string;
badge?: string;
}
/**
* The zone picker's list, Dawarich's own zone and this device's marked, both
* kept in the list even when they are no whole or usual offset.
*/
export function zoneOptions(t: Translate, sourceOffset: number | null, deviceOffset: number): ZoneOption[] {
const offsets = new Set(ZONE_OFFSETS);
if (sourceOffset !== null) offsets.add(sourceOffset);
offsets.add(deviceOffset);
return [...offsets]
.sort((a, b) => a - b)
.map((minutes) => {
const badge =
minutes === sourceOffset
? t('journey.recording.zoneSource')
: minutes === deviceOffset
? t('journey.recording.zoneDevice')
: undefined;
return badge
? { value: minutes, label: formatOffset(minutes), badge }
: { value: minutes, label: formatOffset(minutes) };
});
}
/** The modes Dawarich gives a track, as far as TREK names them; anything else is shown as Dawarich spells it. */
const KNOWN_MODES = new Set([
'walking',
'running',
'cycling',
'driving',
'motorcycle',
'bus',
'train',
'flying',
'boat',
'stationary',
'unknown',
]);
export function modeLabel(mode: string, t: Translate): string {
return KNOWN_MODES.has(mode) ? t(`journey.recording.mode.${mode}`) : mode;
}
/**
* A failed preview, save or removal in the reader's language. The server
* names its reason as a code: Dawarich's own reasons have their strings
* already, the journey's three have theirs.
*/
export function recordingErrorText(err: unknown, t: Translate): string {
if (err instanceof OnlineOnlyError) return t('dawarich.error.offline');
const code = recordingErrorCode(err);
if (code === 'recording_exists' || code === 'recording_changed' || code === 'recording_empty') {
return t(`journey.recording.error.${code}`);
}
if (code) {
const key = `dawarich.error.${code}`;
const text = t(key);
if (text !== key) return text;
}
return t('dawarich.error.unknown');
}
export function recordingErrorCode(err: unknown): string | null {
const code = (err as { response?: { data?: { code?: unknown } } })?.response?.data?.code;
return typeof code === 'string' ? code : null;
}
/**
* The pieces of a day drawn into a box, north up, longitude shortened by the
* latitude so a walk keeps its shape. Answers one SVG path per piece, never a
* line between two of them.
*/
export function sketchPaths(
segments: ReadonlyArray<{ points: ReadonlyArray<readonly unknown[]> }>,
width: number,
height: number,
pad = 8
): { paths: string[]; ends: Array<[number, number]> } {
const all = segments.flatMap((s) => s.points);
if (all.length === 0) return { paths: [], ends: [] };
const lats = all.map((p) => Number(p[0]));
const lngs = all.map((p) => Number(p[1]));
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const shrink = Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180) || 1;
const spanX = Math.max((maxLng - minLng) * shrink, 1e-9);
const spanY = Math.max(maxLat - minLat, 1e-9);
const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY);
const offX = (width - spanX * scale) / 2;
const offY = (height - spanY * scale) / 2;
const at = (point: readonly unknown[]): [number, number] => [
Math.round((offX + (Number(point[1]) - minLng) * shrink * scale) * 10) / 10,
Math.round((offY + (maxLat - Number(point[0])) * scale) * 10) / 10,
];
const paths = segments
.filter((s) => s.points.length > 1)
.map((s) =>
s.points
.map(at)
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x} ${y}`)
.join(' ')
);
const ends = segments.flatMap((s) => {
const first = s.points[0];
const last = s.points[s.points.length - 1];
return s.points.length > 1 && first && last ? [at(first), at(last)] : [];
});
return { paths, ends };
}
@@ -0,0 +1,154 @@
// FE-JRN-RECORDING-DAY-001 to FE-JRN-RECORDING-DAY-007
//
// JRN-01: taking one day's recorded route over, for the desktop dialog and the
// phone sheet alike. Nothing is kept before the button, what is kept is the
// preview that was shown, and a recording that changed in between is shown
// again instead of kept unseen.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import { useJourneyRecordingDay } from './useJourneyRecordingDay';
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const HASH = 'a'.repeat(64);
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
function setup() {
const onKept = vi.fn();
const onRemoved = vi.fn();
const onClose = vi.fn();
// Wrapped, so the strings are the English ones and not their keys.
const view = renderHook(
() => useJourneyRecordingDay({ journeyId: 7, date: '2026-05-14', onKept, onRemoved, onClose }),
{ wrapper: TranslationProvider }
);
return { ...view, onKept, onRemoved, onClose };
}
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
});
describe('useJourneyRecordingDay', () => {
it('FE-JRN-RECORDING-DAY-001: reads the day in the zone Dawarich records in first, and offers to keep it', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const { result } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(repo.preview).toHaveBeenCalledWith(
7,
{
date: '2026-05-14',
utcOffsetMinutes: -new Date('2026-05-14T12:00:00').getTimezoneOffset(),
useSourceOffset: true,
},
expect.any(AbortSignal)
);
expect(result.current.action).toBe('keep');
expect(result.current.zone).toBe(120);
expect(result.current.zoneOptions.find((o) => o.value === 120)?.badge).toBe('From Dawarich');
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-002: another zone reads the day again, cut in that zone', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const { result } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
repo.preview.mockResolvedValue(buildRecordingPreview({ utcOffsetMinutes: -300, offsetSource: 'chosen' }));
act(() => result.current.chooseZone(-300));
await waitFor(() => expect(result.current.zone).toBe(-300));
expect(repo.preview).toHaveBeenLastCalledWith(
7,
{ date: '2026-05-14', utcOffsetMinutes: -300, useSourceOffset: false },
expect.any(AbortSignal)
);
});
it('FE-JRN-RECORDING-DAY-003: keeps what was previewed, in the zone it was cut in, then closes', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const day = buildRecordedDay();
repo.save.mockResolvedValue({ day, outcome: 'created' });
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.action).toBe('keep'));
await act(() => result.current.save());
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
expect(onKept).toHaveBeenCalledWith(day);
expect(toast.success).toHaveBeenCalledWith('Route kept in the journey');
expect(onClose).toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-004: replaces a different kept version only through its own action, and keeps nothing twice', async () => {
const stored = buildRecordedDay({ contentHash: 'b'.repeat(64) });
repo.preview.mockResolvedValue(buildRecordingPreview({ stored }));
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'replaced' });
const { result } = setup();
await waitFor(() => expect(result.current.action).toBe('replace'));
await act(() => result.current.save());
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', {
utcOffsetMinutes: 120,
expectedHash: HASH,
replace: true,
});
repo.save.mockClear();
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
act(() => result.current.retry());
await waitFor(() => expect(result.current.action).toBe('same'));
await act(() => result.current.save());
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-005: a recording that changed since the preview is read again and said so, not kept', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
repo.save.mockRejectedValue(httpError('recording_changed'));
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.action).toBe('keep'));
await act(() => result.current.save());
expect(result.current.changedSincePreview).toBe(true);
await waitFor(() => expect(repo.preview).toHaveBeenCalledTimes(2));
expect(onKept).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-006: says why the day could not be read, and what was recorded nothing of has nothing to keep', async () => {
repo.preview.mockRejectedValueOnce(httpError('not_connected'));
const { result } = setup();
await waitFor(() => expect(result.current.error).toBe('No Dawarich instance is connected yet.'));
expect(result.current.action).toBeNull();
repo.preview.mockResolvedValue(buildRecordingPreview({ contentHash: null, segments: [] }));
act(() => result.current.retry());
await waitFor(() => expect(result.current.action).toBe('empty'));
expect(result.current.error).toBeNull();
});
it('FE-JRN-RECORDING-DAY-007: drops the kept day, and a failed drop or keep says why and stays open', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
repo.remove.mockResolvedValueOnce(undefined);
const { result, onRemoved, onClose } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
await act(() => result.current.remove());
expect(repo.remove).toHaveBeenCalledWith(7, '2026-05-14');
expect(onRemoved).toHaveBeenCalledWith('2026-05-14');
expect(onClose).toHaveBeenCalledTimes(1);
repo.remove.mockRejectedValueOnce(new OnlineOnlyError());
await act(() => result.current.remove());
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('connection'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,151 @@
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { useToast } from '../shared/Toast';
import { deviceOffsetOn, recordingErrorCode, recordingErrorText, zoneOptions } from './journeyRecordingModel';
/** What the desktop dialog and the phone sheet are opened with. */
export interface JourneyRecordingViewProps {
journeyId: number;
journeyTitle: string;
date: string;
/** What the journey keeps for the day, if anything. */
stored: JourneyRecordedDay | undefined;
onKept: (day: JourneyRecordedDay) => void;
onRemoved: (date: string) => void;
onClose: () => void;
}
/**
* What the dialog offers for the day.
* `keep`: nothing kept yet. `replace`: a different version is kept. `same`: this
* very recording is kept already. `empty`: nothing was recorded in this zone.
*/
type RecordingAction = 'keep' | 'replace' | 'same' | 'empty';
/**
* Taking one day's recorded route over into a journey (JRN-01), for the
* desktop dialog and the phone sheet alike.
*
* The preview is read when the dialog opens, cut in Dawarich's own zone where
* it reveals one and in this device's otherwise, and again whenever another
* zone is picked. Nothing is kept before the traveller says so, and what is
* kept is the preview they saw: when Dawarich changed in between, the preview
* is read again and says why instead of keeping something unseen.
*/
export function useJourneyRecordingDay({
journeyId,
date,
onKept,
onRemoved,
onClose,
}: Omit<JourneyRecordingViewProps, 'journeyTitle' | 'stored'>) {
const { t } = useTranslation();
const toast = useToast();
const deviceOffset = useMemo(() => deviceOffsetOn(date), [date]);
const [zone, setZone] = useState({ minutes: deviceOffset, useSource: true });
const [changedSincePreview, setChangedSincePreview] = useState(false);
const [busy, setBusy] = useState<'save' | 'remove' | null>(null);
const [reloadToken, setReloadToken] = useState(0);
// The answer remembers which request it answers: while it is not the one
// asked last, the day is loading, without a flag reset inside the effect.
const [answer, setAnswer] = useState<{ key: string; preview: JourneyRecordingPreview | null; failure: unknown }>(
NO_ANSWER
);
const requestKey = `${journeyId}|${date}|${zone.minutes}|${zone.useSource}|${reloadToken}`;
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
journeyRecordingsRepo
.preview(journeyId, { date, utcOffsetMinutes: zone.minutes, useSourceOffset: zone.useSource }, controller.signal)
.then((preview) => {
if (!cancelled) setAnswer({ key: requestKey, preview, failure: null });
})
.catch((failure: unknown) => {
if (!cancelled) setAnswer({ key: requestKey, preview: null, failure });
});
return () => {
cancelled = true;
controller.abort();
};
}, [journeyId, date, zone, requestKey]);
const loading = answer.key !== requestKey;
const preview = loading ? null : answer.preview;
const failure = loading ? null : answer.failure;
const retry = useCallback(() => setReloadToken((n) => n + 1), []);
const chooseZone = useCallback((minutes: number) => {
setChangedSincePreview(false);
setZone({ minutes, useSource: false });
}, []);
const action: RecordingAction | null = !preview
? null
: preview.contentHash === null
? 'empty'
: preview.sameAsStored
? 'same'
: preview.stored
? 'replace'
: 'keep';
const save = useCallback(async () => {
if (!preview?.contentHash || (action !== 'keep' && action !== 'replace')) return;
setBusy('save');
setChangedSincePreview(false);
try {
const result = await journeyRecordingsRepo.save(journeyId, date, {
utcOffsetMinutes: preview.utcOffsetMinutes,
expectedHash: preview.contentHash,
...(action === 'replace' ? { replace: true } : {}),
});
onKept(result.day);
toast.success(t(action === 'replace' ? 'journey.recording.replacedToast' : 'journey.recording.keptToast'));
onClose();
} catch (err) {
if (recordingErrorCode(err) === 'recording_changed') {
setChangedSincePreview(true);
retry();
} else {
toast.error(recordingErrorText(err, t));
}
} finally {
setBusy(null);
}
}, [preview, action, journeyId, date, onKept, onClose, toast, t, retry]);
const remove = useCallback(async () => {
setBusy('remove');
try {
await journeyRecordingsRepo.remove(journeyId, date);
onRemoved(date);
toast.success(t('journey.recording.removedToast'));
onClose();
} catch (err) {
toast.error(recordingErrorText(err, t));
} finally {
setBusy(null);
}
}, [journeyId, date, onRemoved, onClose, toast, t]);
return {
preview,
loading,
/** Translated; null while nothing failed. */
error: failure === null ? null : recordingErrorText(failure, t),
action,
busy,
changedSincePreview,
zone: zone.useSource && preview ? preview.utcOffsetMinutes : zone.minutes,
zoneOptions: zoneOptions(t, preview?.sourceOffsetMinutes ?? null, deviceOffset),
chooseZone,
retry,
save,
remove,
};
}
const NO_ANSWER = { key: '', preview: null, failure: null };
@@ -0,0 +1,116 @@
// FE-JRN-RECORDINGS-HOOK-001 to FE-JRN-RECORDINGS-HOOK-005
//
// JRN-01: the recorded days a journey keeps, as the desktop page and the phone
// screen read them: the list, the lines they draw on the map, which day's
// dialog is open, and a fresh read whenever somebody keeps or drops a day.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, WALK_SEGMENT } from '../../../tests/helpers/journeyRecording';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { useJourneyRecordings } from './useJourneyRecordings';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const pings = vi.hoisted(() => ({ handlers: new Map<string, () => Promise<void>>() }));
vi.mock('../../sync/useServerPing', () => ({
useServerPing: (event: string, enabled: boolean, reload: () => Promise<void>) => {
if (enabled) pings.handlers.set(event, reload);
},
}));
const JOURNEY = { id: 7, title: 'Venice' };
const colorOf = (date: string) => (date === '2026-05-14' ? '#ff0055' : null);
beforeEach(() => {
repo.list.mockReset();
pings.handlers.clear();
});
describe('useJourneyRecordings', () => {
it('FE-JRN-RECORDINGS-HOOK-001: reads the kept days and draws each piece as a recorded line in its day’s colour', async () => {
const second = {
...WALK_SEGMENT,
points: [
[45.44, 12.33],
[45.45, 12.34],
] as Array<[number, number]>,
};
repo.list.mockResolvedValue([
buildRecordedDay({ segments: [WALK_SEGMENT, second] }),
buildRecordedDay({ id: 4, date: '2026-05-15' }),
]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(result.current.days).toHaveLength(2));
expect(repo.list).toHaveBeenCalledWith(7);
expect(result.current.byDate.get('2026-05-15')?.id).toBe(4);
expect(result.current.tracks).toHaveLength(3);
expect(result.current.tracks.every((track) => track.kind === 'day_recording')).toBe(true);
// One line per piece: a gap between two pieces is never drawn over.
expect(result.current.tracks.slice(0, 2).map((track) => track.points)).toEqual([
WALK_SEGMENT.points,
second.points,
]);
expect(result.current.tracks[0]?.color).toBe('#ff0055');
// A day without entries takes the colour of the live recording.
expect(result.current.tracks[2]?.color).toBe('#2563EB');
const ids = result.current.tracks.map((track) => track.place_id);
expect(ids.every((id) => id < -1000)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it('FE-JRN-RECORDINGS-HOOK-002: no journey yet, nothing asked', () => {
const { result } = renderHook(() => useJourneyRecordings(null, colorOf));
expect(repo.list).not.toHaveBeenCalled();
expect(result.current.tracks).toEqual([]);
expect(result.current.dialog).toBeNull();
});
it('FE-JRN-RECORDINGS-HOOK-003: a failed read keeps what the map already showed', async () => {
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(result.current.days).toHaveLength(1));
repo.list.mockRejectedValueOnce(new Error('offline'));
await act(async () => {
await pings.handlers.get('journey:recording:updated')?.();
});
expect(result.current.days).toHaveLength(1);
});
it('FE-JRN-RECORDINGS-HOOK-004: reads again when somebody on the journey keeps or drops a day', async () => {
repo.list.mockResolvedValueOnce([]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(repo.list).toHaveBeenCalledTimes(1));
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
await act(async () => {
await pings.handlers.get('journey:recording:updated')?.();
});
expect(result.current.byDate.has('2026-05-14')).toBe(true);
});
it('FE-JRN-RECORDINGS-HOOK-005: opens a day’s dialog with what it keeps, and shows a kept or dropped day at once', async () => {
repo.list.mockResolvedValue([]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(repo.list).toHaveBeenCalled());
act(() => result.current.open('2026-05-14'));
expect(result.current.dialog).toMatchObject({
journeyId: 7,
journeyTitle: 'Venice',
date: '2026-05-14',
stored: undefined,
});
act(() => result.current.kept(buildRecordedDay()));
expect(result.current.dialog?.stored?.id).toBe(3);
expect(result.current.tracks).toHaveLength(1);
act(() => result.current.removed('2026-05-14'));
expect(result.current.days).toEqual([]);
act(() => result.current.close());
expect(result.current.dialog).toBeNull();
});
});
@@ -0,0 +1,139 @@
import type { JourneyRecordedDay, JourneyTrack } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { useServerPing } from '../../sync/useServerPing';
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
import type { JourneyRecordingViewProps } from './useJourneyRecordingDay';
interface JourneyRecordingsState {
days: JourneyRecordedDay[];
byDate: ReadonlyMap<string, JourneyRecordedDay>;
/** The kept days as lines for the journey map, one per piece, never joined across a gap. */
tracks: JourneyTrack[];
/** The day whose recording dialog is open, or null. */
openDate: string | null;
/** What the desktop dialog or the phone sheet opens with while a day is open. */
dialog: JourneyRecordingViewProps | null;
open: (date: string) => void;
close: () => void;
/** A day was kept or replaced from the dialog: shown at once, without asking again. */
kept: (day: JourneyRecordedDay) => void;
removed: (date: string) => void;
}
/**
* The recorded days a journey keeps (JRN-01), for the desktop page and the
* phone screen alike: the list, the lines they draw on the journey map and
* which day's dialog is open. Read again whenever somebody on the journey
* keeps or drops a day. Offline the days stay as last read; a journey has no
* offline copy to fall back on.
*
* `colorOf` gives a day the colour its entries have on the map, so a kept
* route and the pins of its day read as one; a day without entries takes the
* colour of the live recording.
*/
export function useJourneyRecordings(
journey: { id: number; title: string } | null,
colorOf: (date: string) => string | null
): JourneyRecordingsState {
const journeyId = journey?.id ?? null;
const journeyTitle = journey?.title ?? '';
const { t, locale } = useTranslation();
// Held with the journey they belong to, so a journey opened after another
// never shows the other one's days, and nothing has to be reset in an effect.
const [loaded, setLoaded] = useState<{ journeyId: number | null; days: JourneyRecordedDay[] }>(NOTHING_LOADED);
const [opened, setOpened] = useState<{ journeyId: number | null; date: string | null }>(NOTHING_OPEN);
const days = loaded.journeyId === journeyId ? loaded.days : NO_DAYS;
const openDate = opened.journeyId === journeyId ? opened.date : null;
const read = useCallback(async () => {
if (journeyId === null) return null;
try {
return { journeyId, days: await journeyRecordingsRepo.list(journeyId) };
} catch {
// What was read before stays on the map; a kept day does not vanish
// because one read of it failed.
return null;
}
}, [journeyId]);
useEffect(() => {
let cancelled = false;
void read().then((next) => {
if (next && !cancelled) setLoaded(next);
});
return () => {
cancelled = true;
};
}, [read]);
const reload = useCallback(async () => {
const next = await read();
if (next) setLoaded(next);
}, [read]);
useServerPing('journey:recording:updated', journeyId !== null, reload);
const byDate = useMemo(() => new Map(days.map((day) => [day.date, day])), [days]);
const tracks = useMemo(
() =>
days.flatMap((day) => {
const name = t('journey.recording.mapLine', {
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const color = colorOf(day.date) ?? DAWARICH_TRAIL_COLOR;
return day.segments.map((segment, index): JourneyTrack => ({
// Negative and far from the live trail's -1, -2, ... so the map keys never meet.
place_id: -(100_000 + day.id * 1_000 + index),
trip_id: 0,
name,
color,
points: segment.points,
kind: 'day_recording',
}));
}),
[days, colorOf, t, locale]
);
const kept = useCallback(
(day: JourneyRecordedDay) =>
setLoaded((prev) => ({
journeyId,
days: [...prev.days.filter((d) => d.date !== day.date), day].sort((a, b) => (a.date < b.date ? -1 : 1)),
})),
[journeyId]
);
const removed = useCallback(
(date: string) => setLoaded((prev) => ({ journeyId, days: prev.days.filter((d) => d.date !== date) })),
[journeyId]
);
const open = useCallback((date: string) => setOpened({ journeyId, date }), [journeyId]);
const close = useCallback(() => setOpened(NOTHING_OPEN), []);
const dialog = useMemo(
() =>
journeyId !== null && openDate
? {
journeyId,
journeyTitle,
date: openDate,
stored: byDate.get(openDate),
onKept: kept,
onRemoved: removed,
onClose: close,
}
: null,
[journeyId, journeyTitle, openDate, byDate, kept, removed, close]
);
return { days, byDate, tracks, openDate, dialog, open, close, kept, removed };
}
/** Stable empty identity, so a journey without kept days never re-renders its map for nothing. */
const NO_DAYS: JourneyRecordedDay[] = [];
const NOTHING_LOADED = { journeyId: null, days: NO_DAYS };
const NOTHING_OPEN = { journeyId: null, date: null };