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
+54
View File
@@ -0,0 +1,54 @@
// FE-API-JOURNEY-RECORDINGS-001
//
// JRN-01: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { journeyRecordingsApi } from './journeyRecordings';
const BASE = '/api/journeys/7/recordings';
const HASH = 'a'.repeat(64);
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return HttpResponse.json(data);
};
}
describe('journeyRecordingsApi', () => {
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
server.use(
http.get(BASE, record({ days: [] })),
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
);
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
expect(
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
).toEqual({ date: '2026-05-14' });
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
outcome: 'created',
});
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{
method: 'POST',
path: `${BASE}/preview`,
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
},
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
]);
});
});
+30
View File
@@ -0,0 +1,30 @@
import type {
JourneyRecordedDaysResponse,
JourneyRecordingPreview,
JourneyRecordingPreviewRequest,
JourneyRecordingSaveRequest,
JourneyRecordingSaveResult,
} from '@trek/shared';
import apiClient from './client';
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
/** The preview and the save ask Dawarich first, so they get its longer wait. */
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
/** JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it. */
export const journeyRecordingsApi = {
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
preview: (
journeyId: number,
body: JourneyRecordingPreviewRequest,
signal?: AbortSignal
): Promise<JourneyRecordingPreview> =>
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
remove: (journeyId: number, date: string): Promise<void> =>
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
};
+3
View File
@@ -81,6 +81,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Capture times landed after a photo import (#1587); the same listener reloads
// the journey so the gallery re-sorts.
'journey:photos:updated',
// A day's recorded route was taken over or removed (JRN-01):
// components/Journey/useJourneyRecordings reads the kept days again.
'journey:recording:updated',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
@@ -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 };
@@ -226,6 +226,7 @@ describe('useDawarichJournalTrail', () => {
name: DAY_ONE,
color: DAWARICH_DAY_COLORS[0],
points: [[53.5, 10.0], [53.6, 10.1]],
kind: 'dawarich_live',
},
{
place_id: -2,
@@ -235,6 +236,7 @@ describe('useDawarichJournalTrail', () => {
// in the first day's colour would read as one continuous recording.
color: DAWARICH_DAY_COLORS[1],
points: [[48.1, 11.5], [48.2, 11.6], [48.3, 11.7]],
kind: 'dawarich_live',
},
])
// The journey map keys its polylines on place_id. Real places are positive,
@@ -71,6 +71,7 @@ export function useDawarichJournalTrail(
name: segment.date,
color: segment.color,
points: segment.points,
kind: 'dawarich_live',
})),
)
setFetchedAt(track.fetchedAt)
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X } from 'lucide-react'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
@@ -27,6 +27,7 @@ import { useJourneyCarouselSync } from '../../../components/Journey/useJourneyCa
import { useGalleryUpload } from '../../../components/Journey/useGalleryUpload'
import MJourneyEntrySheet from './MJourneyEntrySheet'
import MJourneySettingsSheet from './MJourneySettingsSheet'
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
/**
* Journey detail — integrated map with the horizontal 280px card timeline
@@ -48,7 +49,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showInvite, setShowInvite,
showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
sidebarMapItems, tracks,
sidebarMapItems, tracks, recordings, canTakeOverRecording,
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
@@ -507,6 +508,18 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold">{t('journey.skeletons.hide')}</span>
<MToggle checked={hideSkeletons} onChange={toggleSkeletons} ariaLabel={t('journey.skeletons.hide')} />
</div>
{/* The recorded route of the day the card rail is on (JRN-01): taking it
over, or checking, replacing or dropping the one kept. */}
{canEditEntries && view === 'timeline' && entries[activeIndex] &&
(canTakeOverRecording || recordings.byDate.has(entries[activeIndex].entry_date)) && (
<MListRow
icon={Route}
label={t('journey.recording.takeOverDay', {
date: new Date(entries[activeIndex].entry_date + 'T00:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'long' }),
})}
onClick={() => { setShowActionMenu(false); recordings.open(entries[activeIndex].entry_date) }}
/>
)}
{canEditJourney && (
<MListRow
icon={Settings2}
@@ -570,6 +583,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
/>
)}
{recordings.dialog && <MJourneyRecordingSheet {...recordings.dialog} />}
{/* Journey settings */}
{showSettings && (
<MJourneySettingsSheet
@@ -0,0 +1,55 @@
import { JourneyRecordingActions, JourneyRecordingReview } from '../../../components/Journey/JourneyRecordingReview';
import {
useJourneyRecordingDay,
type JourneyRecordingViewProps,
} from '../../../components/Journey/useJourneyRecordingDay';
import DawarichIcon from '../../../components/shared/DawarichIcon';
import { useTranslation } from '../../../i18n';
import MSheet from '../../components/MSheet';
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome';
/**
* Taking one day's recorded route from Dawarich into the journey (JRN-01), on
* the phone: the same preview and the same buttons as the desktop dialog, in a
* bottom sheet, for the day of the card the journey is showing.
*/
export default function MJourneyRecordingSheet({
journeyTitle,
date,
stored,
onClose,
...rest
}: JourneyRecordingViewProps) {
const { t, locale } = useTranslation();
const day = useJourneyRecordingDay({ date, onClose, ...rest });
const title = new Date(`${date}T00:00:00`).toLocaleDateString(locale, {
weekday: 'long',
day: 'numeric',
month: 'long',
});
return (
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={t('journey.recording.takeOver')}>
<div className="flex max-h-[86dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={title}
subtitle={day.preview?.journey.title ?? journeyTitle}
onClose={onClose}
closeLabel={t('common.close')}
/>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-[18px] pb-4 pt-2">
<p className="m-0 font-geist text-caption text-m-muted">{t('journey.recording.sub')}</p>
<JourneyRecordingReview day={day} stored={stored} />
</div>
<div className="flex flex-none items-center gap-2 border-t border-edge-faint px-[18px] py-3">
<JourneyRecordingActions day={day} stored={stored} onCancel={onClose} />
</div>
</div>
</MSheet>
);
}
+15 -1
View File
@@ -15,6 +15,8 @@ import {
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
import JourneyDayRecordingButton from '../components/Journey/JourneyDayRecordingButton'
import JourneyRecordingDialog from '../components/Journey/JourneyRecordingDialog'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
@@ -64,6 +66,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos,
recordings, canTakeOverRecording, recordingNeedsConnection,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos,
@@ -114,7 +117,9 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
const suggestionDates = canEditEntries && !query && spanDates.length
? [...dawarichByDate.keys()].filter(d => d >= spanDates[0] && d <= spanDates[spanDates.length - 1])
: []
const sortedDates = [...new Set([...dayGroups.keys(), ...suggestionDates])]
// A day that keeps its recorded route stays a day of the journey, entries or not.
const recordedDates = query ? [] : [...recordings.byDate.keys()]
const sortedDates = [...new Set([...dayGroups.keys(), ...suggestionDates, ...recordedDates])]
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null, current.status_override)
@@ -497,6 +502,13 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places', { count: entries.length })}</span>
<JourneyDayRecordingButton
day={recordings.byDate.get(date)}
canEdit={canEditEntries}
canTakeOver={canTakeOverRecording}
needsConnection={recordingNeedsConnection}
onOpen={() => recordings.open(date)}
/>
{/* The only Add button used to be at the very top and always
started on today, so putting something into an earlier day
meant correcting the date by hand (discussion #2299). */}
@@ -766,6 +778,8 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
/>
)}
{recordings.dialog && <JourneyRecordingDialog {...recordings.dialog} />}
{/* Add Trip Dialog */}
{showAddTrip && current && (
<AddTripDialog
@@ -17,6 +17,7 @@ import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionMod
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'
import { useJourneyDetailRecordings } from './useJourneyDetailRecordings'
import { useProviderPhotoAdds } from './useProviderPhotoAdds'
/** Stable identity for "this journey draws no trip tracks" (#2194). */
@@ -530,10 +531,16 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
// the same reason that switch exists — it gates the REQUEST, and a map nobody
// asked for should not reach across the network for it.
const dawarichTrail = useDawarichJournalTrail(tripDates, showTripTracks)
const mapTracks = useMemo(
() => (dawarichTrail.tracks.length > 0 ? [...tracks, ...dawarichTrail.tracks] : tracks),
[tracks, dawarichTrail.tracks],
)
const { recordings, canTakeOverRecording, recordingNeedsConnection, mapTracks } = useJourneyDetailRecordings({
loaded: journeyLoaded,
journey: current,
canEdit: canEditEntries,
dawarichStatus: dawarich.status,
sidebarMapItems,
tripTracks: tracks,
liveTracks: dawarichTrail.tracks,
})
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
const STUDIO_INSET = 16
@@ -581,6 +588,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
recordings, canTakeOverRecording, recordingNeedsConnection,
mapPhotos, openMapPhotos,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
@@ -0,0 +1,84 @@
// FE-JRN-DETAIL-RECORDINGS-001 to FE-JRN-DETAIL-RECORDINGS-003
//
// JRN-01: the journey page's map lines, with the days the journey keeps beside
// the trips' GPX tracks and the live Dawarich trail.
import type { JourneyTrack } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay } from '../../../tests/helpers/journeyRecording';
import { renderHook, waitFor } from '../../../tests/helpers/render';
import { useJourneyDetailRecordings } from './useJourneyDetailRecordings';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const line = (over: Partial<JourneyTrack>): JourneyTrack => ({
place_id: 1,
trip_id: 1,
name: 'Ridge',
color: '#aa0000',
points: [
[47, 11],
[47.1, 11.1],
],
...over,
});
const GPX = [line({})];
const LIVE = [
line({ place_id: -1, trip_id: 0, name: '2026-05-14', kind: 'dawarich_live' }),
line({ place_id: -2, trip_id: 0, name: '2026-05-15', kind: 'dawarich_live' }),
];
const ITEMS = [{ entry_date: '2026-05-14', dayColor: '#ff0055' }];
function setup(over: Partial<Parameters<typeof useJourneyDetailRecordings>[0]> = {}) {
return renderHook(() =>
useJourneyDetailRecordings({
loaded: true,
journey: { id: 7, title: 'Venice' },
canEdit: true,
dawarichStatus: 'ready',
sidebarMapItems: ITEMS,
tripTracks: GPX,
liveTracks: LIVE,
...over,
})
);
}
beforeEach(() => {
repo.list.mockReset();
});
describe('useJourneyDetailRecordings', () => {
it('FE-JRN-DETAIL-RECORDINGS-001: a kept day replaces the live trail of its date and takes the colour of its pins', async () => {
repo.list.mockResolvedValue([buildRecordedDay()]);
const { result } = setup();
await waitFor(() => expect(result.current.recordings.days).toHaveLength(1));
const kinds = result.current.mapTracks.map((track) => track.kind ?? 'trip_gpx');
expect(kinds).toEqual(['trip_gpx', 'day_recording', 'dawarich_live']);
expect(result.current.mapTracks[1]?.color).toBe('#ff0055');
expect(result.current.mapTracks[2]?.name).toBe('2026-05-15');
});
it('FE-JRN-DETAIL-RECORDINGS-002: with nothing kept and no live trail the GPX tracks pass through as they are', async () => {
repo.list.mockResolvedValue([]);
const { result } = setup({ liveTracks: [] });
await waitFor(() => expect(repo.list).toHaveBeenCalledWith(7));
expect(result.current.mapTracks).toBe(GPX);
});
it('FE-JRN-DETAIL-RECORDINGS-003: only an editor whose Dawarich answers may take a day over; offline says so', () => {
repo.list.mockResolvedValue([]);
expect(setup().result.current).toMatchObject({ canTakeOverRecording: true, recordingNeedsConnection: false });
expect(setup({ dawarichStatus: 'offline' }).result.current).toMatchObject({
canTakeOverRecording: false,
recordingNeedsConnection: true,
});
expect(setup({ canEdit: false }).result.current).toMatchObject({
canTakeOverRecording: false,
recordingNeedsConnection: false,
});
expect(setup({ dawarichStatus: 'disconnected' }).result.current.canTakeOverRecording).toBe(false);
});
});
@@ -0,0 +1,55 @@
import type { JourneyTrack } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useJourneyRecordings } from '../../components/Journey/useJourneyRecordings';
import type { DawarichSuggestionsStatus } from '../../hooks/useDawarichSuggestions';
/**
* The recorded days a journey keeps (JRN-01) as the journey page wires them:
* their lines in the colour of the pins of their day, beside the trips' GPX
* tracks and the live Dawarich trail. A kept day is the journey's own, so it
* draws whatever the GPX switch says, and wins over the live trail of its date.
*/
export function useJourneyDetailRecordings({
loaded,
journey: current,
canEdit,
dawarichStatus,
sidebarMapItems,
tripTracks,
liveTracks,
}: {
/** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */
loaded: boolean;
journey: { id: number; title: string } | null | undefined;
canEdit: boolean;
dawarichStatus: DawarichSuggestionsStatus;
sidebarMapItems: ReadonlyArray<{ entry_date: string; dayColor: string }>;
tripTracks: JourneyTrack[];
liveTracks: JourneyTrack[];
}) {
const dayColorByDate = useMemo(
() => new Map(sidebarMapItems.map((item) => [item.entry_date, item.dayColor])),
[sidebarMapItems]
);
const colorOfDay = useCallback((date: string) => dayColorByDate.get(date) ?? null, [dayColorByDate]);
const journeyId = loaded && current ? current.id : null;
const journeyTitle = current?.title ?? '';
const journey = useMemo(
() => (journeyId === null ? null : { id: journeyId, title: journeyTitle }),
[journeyId, journeyTitle]
);
const recordings = useJourneyRecordings(journey, colorOfDay);
const mapTracks = useMemo(() => {
if (recordings.tracks.length === 0 && liveTracks.length === 0) return tripTracks;
const live = liveTracks.filter((track) => !recordings.byDate.has(track.name));
return [...tripTracks, ...recordings.tracks, ...live];
}, [tripTracks, recordings.tracks, recordings.byDate, liveTracks]);
return {
recordings,
canTakeOverRecording: canEdit && dawarichStatus === 'ready',
recordingNeedsConnection: canEdit && dawarichStatus === 'offline',
mapTracks,
};
}
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, buildRecordingPreview } from '../../tests/helpers/journeyRecording';
import { journeyRecordingsApi } from '../api/journeyRecordings';
import { journeyRecordingsRepo } from './journeyRecordingsRepo';
import { OnlineOnlyError } from './onlineOnly';
// FE-REPO-JOURNEY-RECORDINGS-001 to FE-REPO-JOURNEY-RECORDINGS-003
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
const HASH = 'a'.repeat(64);
beforeEach(() => {
network.offline = false;
vi.restoreAllMocks();
});
describe('journeyRecordingsRepo', () => {
it('FE-REPO-JOURNEY-RECORDINGS-001: asks nothing offline, a journey keeps no offline copy of its recorded days', async () => {
network.offline = true;
const list = vi.spyOn(journeyRecordingsApi, 'list');
await expect(journeyRecordingsRepo.list(7)).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(journeyRecordingsRepo.preview(7, { date: '2026-05-14', utcOffsetMinutes: 0 })).rejects.toBeInstanceOf(
OnlineOnlyError
);
await expect(
journeyRecordingsRepo.save(7, '2026-05-14', { utcOffsetMinutes: 0, expectedHash: HASH })
).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(journeyRecordingsRepo.remove(7, '2026-05-14')).rejects.toBeInstanceOf(OnlineOnlyError);
expect(list).not.toHaveBeenCalled();
});
it('FE-REPO-JOURNEY-RECORDINGS-002: passes every call on and answers what the contract describes', async () => {
const day = buildRecordedDay();
vi.spyOn(journeyRecordingsApi, 'list').mockResolvedValue({ days: [day] });
vi.spyOn(journeyRecordingsApi, 'preview').mockResolvedValue(buildRecordingPreview());
const save = vi.spyOn(journeyRecordingsApi, 'save').mockResolvedValue({ day, outcome: 'created' });
const remove = vi.spyOn(journeyRecordingsApi, 'remove').mockResolvedValue(undefined);
expect(await journeyRecordingsRepo.list(7)).toEqual([day]);
expect((await journeyRecordingsRepo.preview(7, { date: '2026-05-14', utcOffsetMinutes: 0 })).journey.title).toBe(
'Venice'
);
expect(
(await journeyRecordingsRepo.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).outcome
).toBe('created');
await journeyRecordingsRepo.remove(7, '2026-05-14');
expect(save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
expect(remove).toHaveBeenCalledWith(7, '2026-05-14');
});
it('FE-REPO-JOURNEY-RECORDINGS-003: an answer the contract does not describe fails here, not on the map', async () => {
vi.spyOn(journeyRecordingsApi, 'list').mockResolvedValue({ days: [{ date: '2026-05-14' }] } as never);
await expect(journeyRecordingsRepo.list(7)).rejects.toThrow();
});
});
+44
View File
@@ -0,0 +1,44 @@
import {
journeyRecordedDaysResponseSchema,
journeyRecordingPreviewSchema,
journeyRecordingSaveResultSchema,
type JourneyRecordedDay,
type JourneyRecordingPreview,
type JourneyRecordingPreviewRequest,
type JourneyRecordingSaveRequest,
type JourneyRecordingSaveResult,
} from '@trek/shared';
import { journeyRecordingsApi } from '../api/journeyRecordings';
import { requireOnline } from './onlineOnly';
/**
* JRN-01, online only on purpose, like the journey itself: the days a journey
* keeps are read when it is open, and taking one over asks Dawarich, which
* needs the network anyway. Answers are parsed against the shared contract,
* so a server that answered something else fails here and not on the map.
*/
export const journeyRecordingsRepo = {
async list(journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDay[]> {
requireOnline();
return journeyRecordedDaysResponseSchema.parse(await journeyRecordingsApi.list(journeyId, signal)).days;
},
async preview(
journeyId: number,
body: JourneyRecordingPreviewRequest,
signal?: AbortSignal
): Promise<JourneyRecordingPreview> {
requireOnline();
return journeyRecordingPreviewSchema.parse(await journeyRecordingsApi.preview(journeyId, body, signal));
},
async save(journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> {
requireOnline();
return journeyRecordingSaveResultSchema.parse(await journeyRecordingsApi.save(journeyId, date, body));
},
async remove(journeyId: number, date: string): Promise<void> {
requireOnline();
await journeyRecordingsApi.remove(journeyId, date);
},
};