From 65bb2d95eba025e903733ed5358d005655e4b3c0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 21 Oct 2026 15:05:21 +0200 Subject: [PATCH] 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. --- client/scripts/feature-pairs.json | 6 + client/src/api/journeyRecordings.test.ts | 54 +++ client/src/api/journeyRecordings.ts | 30 ++ client/src/api/wsEventPolicy.ts | 3 + .../JourneyDayRecordingButton.test.tsx | 61 +++ .../Journey/JourneyDayRecordingButton.tsx | 86 +++++ .../components/Journey/JourneyMap.test.tsx | 29 +- client/src/components/Journey/JourneyMap.tsx | 15 +- .../components/Journey/JourneyMapGL.test.tsx | 30 +- .../src/components/Journey/JourneyMapGL.tsx | 23 +- .../Journey/JourneyRecordingDialog.test.tsx | 103 +++++ .../Journey/JourneyRecordingDialog.tsx | 61 +++ .../Journey/JourneyRecordingReview.tsx | 286 ++++++++++++++ .../Journey/journeyRecordingModel.test.ts | 98 +++++ .../Journey/journeyRecordingModel.ts | 154 ++++++++ .../Journey/useJourneyRecordingDay.test.ts | 154 ++++++++ .../Journey/useJourneyRecordingDay.ts | 151 ++++++++ .../Journey/useJourneyRecordings.test.ts | 116 ++++++ .../Journey/useJourneyRecordings.ts | 139 +++++++ .../src/hooks/useDawarichJournalTrail.test.ts | 2 + client/src/hooks/useDawarichJournalTrail.ts | 1 + .../mobile/screens/journey/MJourneyDetail.tsx | 19 +- .../journey/MJourneyRecordingSheet.tsx | 55 +++ client/src/pages/JourneyDetailPage.tsx | 16 +- .../pages/journeyDetail/useJourneyDetail.ts | 16 +- .../useJourneyDetailRecordings.test.ts | 84 +++++ .../useJourneyDetailRecordings.ts | 55 +++ client/src/repo/journeyRecordingsRepo.test.ts | 57 +++ client/src/repo/journeyRecordingsRepo.ts | 44 +++ client/tests/helpers/journeyRecording.ts | 64 ++++ server/scripts/repository-owners.json | 1 + .../db/entities/JourneyRecordedDays.entity.ts | 60 +++ server/src/db/entities/Journeys.entity.ts | 3 + server/src/db/entities/Users.entity.ts | 3 + server/src/db/entities/index.ts | 2 + server/src/db/json-columns.ts | 13 + .../db/kysely/JourneyRecordedDays.table.ts | 23 ++ server/src/db/kysely/db.ts | 2 + ...46160_a_journey_keeps_its_recorded_days.ts | 48 +++ .../JourneyRecordedDays.repository.ts | 125 +++++++ server/src/nest/app.module.ts | 2 + .../integrations/dawarich-tracks.service.ts | 124 +++++- .../src/nest/integrations/dawarich.helpers.ts | 119 +++++- server/src/nest/integrations/dawarich.mcp.ts | 2 +- .../journey-recordings.controller.ts | 85 +++++ .../journey-recordings.dto.ts | 6 + .../journey-recordings.mcp.ts | 111 ++++++ .../journey-recordings.module.ts | 20 + .../journey-recordings.service.ts | 140 +++++++ .../src/nest/journey/journey-domain.module.ts | 10 +- .../journey/journey-recorded-days.service.ts | 118 ++++++ .../tests/e2e/journey-recordings.e2e.test.ts | 295 +++++++++++++++ server/tests/unit/db/entities-index.test.ts | 7 +- .../tests/unit/db/entity-class-fields.test.ts | 4 +- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../JourneyRecordedDays.repository.test.ts | 155 ++++++++ .../unit/nest/dawarich-tracks.service.test.ts | 154 +++++++- .../tests/unit/nest/dawarich.helpers.test.ts | 71 ++++ .../journey-recordings.controller.test.ts | 93 +++++ .../unit/nest/journey-recordings.mcp.test.ts | 179 +++++++++ .../nest/journey-recordings.service.test.ts | 352 ++++++++++++++++++ server/vitest.config.ts | 3 + shared/src/dawarich/dawarich.schema.ts | 4 +- shared/src/i18n/ar/journey.ts | 74 ++++ shared/src/i18n/az/journey.ts | 63 ++++ shared/src/i18n/br/journey.ts | 64 ++++ shared/src/i18n/ca/journey.ts | 63 ++++ shared/src/i18n/cs/journey.ts | 65 ++++ shared/src/i18n/de/journey.ts | 65 ++++ shared/src/i18n/en/journey.ts | 63 ++++ shared/src/i18n/es/journey.ts | 63 ++++ shared/src/i18n/et/journey.ts | 62 +++ shared/src/i18n/fr/journey.ts | 63 ++++ shared/src/i18n/gr/journey.ts | 64 ++++ shared/src/i18n/hu/journey.ts | 63 ++++ shared/src/i18n/id/journey.ts | 60 +++ shared/src/i18n/it/journey.ts | 64 ++++ shared/src/i18n/ja/journey.ts | 58 +++ shared/src/i18n/ko/journey.ts | 57 +++ shared/src/i18n/nl/journey.ts | 64 ++++ shared/src/i18n/pl/journey.ts | 71 ++++ shared/src/i18n/ru/journey.ts | 70 ++++ shared/src/i18n/sk/journey.ts | 65 ++++ shared/src/i18n/sv/journey.ts | 62 +++ shared/src/i18n/th/journey.ts | 55 +++ shared/src/i18n/tr/journey.ts | 62 +++ shared/src/i18n/uk/journey.ts | 72 ++++ shared/src/i18n/vi/journey.ts | 59 +++ shared/src/i18n/zh-TW/journey.ts | 53 +++ shared/src/i18n/zh/journey.ts | 53 +++ shared/src/index.ts | 1 + .../journey/journey-recordings.schema.spec.ts | 119 ++++++ .../src/journey/journey-recordings.schema.ts | 193 ++++++++++ shared/src/journey/journey.schema.ts | 6 + shared/src/realtime/events.schema.spec.ts | 9 +- shared/src/realtime/events.schema.ts | 5 + wiki/Dawarich.md | 6 +- wiki/Journey-Journal.md | 23 +- wiki/MCP-Addon-Tools.md | 4 + 99 files changed, 6359 insertions(+), 51 deletions(-) create mode 100644 client/src/api/journeyRecordings.test.ts create mode 100644 client/src/api/journeyRecordings.ts create mode 100644 client/src/components/Journey/JourneyDayRecordingButton.test.tsx create mode 100644 client/src/components/Journey/JourneyDayRecordingButton.tsx create mode 100644 client/src/components/Journey/JourneyRecordingDialog.test.tsx create mode 100644 client/src/components/Journey/JourneyRecordingDialog.tsx create mode 100644 client/src/components/Journey/JourneyRecordingReview.tsx create mode 100644 client/src/components/Journey/journeyRecordingModel.test.ts create mode 100644 client/src/components/Journey/journeyRecordingModel.ts create mode 100644 client/src/components/Journey/useJourneyRecordingDay.test.ts create mode 100644 client/src/components/Journey/useJourneyRecordingDay.ts create mode 100644 client/src/components/Journey/useJourneyRecordings.test.ts create mode 100644 client/src/components/Journey/useJourneyRecordings.ts create mode 100644 client/src/mobile/screens/journey/MJourneyRecordingSheet.tsx create mode 100644 client/src/pages/journeyDetail/useJourneyDetailRecordings.test.ts create mode 100644 client/src/pages/journeyDetail/useJourneyDetailRecordings.ts create mode 100644 client/src/repo/journeyRecordingsRepo.test.ts create mode 100644 client/src/repo/journeyRecordingsRepo.ts create mode 100644 client/tests/helpers/journeyRecording.ts create mode 100644 server/src/db/entities/JourneyRecordedDays.entity.ts create mode 100644 server/src/db/kysely/JourneyRecordedDays.table.ts create mode 100644 server/src/db/migrations/Migration20200101046160_a_journey_keeps_its_recorded_days.ts create mode 100644 server/src/db/repositories/JourneyRecordedDays.repository.ts create mode 100644 server/src/nest/journey-recordings/journey-recordings.controller.ts create mode 100644 server/src/nest/journey-recordings/journey-recordings.dto.ts create mode 100644 server/src/nest/journey-recordings/journey-recordings.mcp.ts create mode 100644 server/src/nest/journey-recordings/journey-recordings.module.ts create mode 100644 server/src/nest/journey-recordings/journey-recordings.service.ts create mode 100644 server/src/nest/journey/journey-recorded-days.service.ts create mode 100644 server/tests/e2e/journey-recordings.e2e.test.ts create mode 100644 server/tests/unit/db/repositories/JourneyRecordedDays.repository.test.ts create mode 100644 server/tests/unit/nest/journey-recordings.controller.test.ts create mode 100644 server/tests/unit/nest/journey-recordings.mcp.test.ts create mode 100644 server/tests/unit/nest/journey-recordings.service.test.ts create mode 100644 shared/src/journey/journey-recordings.schema.spec.ts create mode 100644 shared/src/journey/journey-recordings.schema.ts diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 27dc71d47..c7908f5bd 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -399,6 +399,12 @@ "src/mobile/screens/journey/mobileJourneyMeta.ts" ] }, + { + "feature": "Journey day recording: useJourneyRecordingDay", + "hooks": ["src/components/Journey/useJourneyRecordingDay.ts"], + "desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"], + "mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"] + }, { "feature": "Journey detail page: useJourneyDetail", "hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"], diff --git a/client/src/api/journeyRecordings.test.ts b/client/src/api/journeyRecordings.test.ts new file mode 100644 index 000000000..4b0e5ad1c --- /dev/null +++ b/client/src/api/journeyRecordings.test.ts @@ -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 }, + ]); + }); +}); diff --git a/client/src/api/journeyRecordings.ts b/client/src/api/journeyRecordings.ts new file mode 100644 index 000000000..1b382c742 --- /dev/null +++ b/client/src/api/journeyRecordings.ts @@ -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 => + apiClient.get(base(journeyId), { signal }).then((r) => r.data), + preview: ( + journeyId: number, + body: JourneyRecordingPreviewRequest, + signal?: AbortSignal + ): Promise => + apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data), + save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise => + apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data), + remove: (journeyId: number, date: string): Promise => + apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined), +}; diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index a7db00eda..db6bf4465 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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) diff --git a/client/src/components/Journey/JourneyDayRecordingButton.test.tsx b/client/src/components/Journey/JourneyDayRecordingButton.test.tsx new file mode 100644 index 000000000..906b56eae --- /dev/null +++ b/client/src/components/Journey/JourneyDayRecordingButton.test.tsx @@ -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[0]> = {}) { + const onOpen = vi.fn(); + render( + + ); + 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(); + }); +}); diff --git a/client/src/components/Journey/JourneyDayRecordingButton.tsx b/client/src/components/Journey/JourneyDayRecordingButton.tsx new file mode 100644 index 000000000..41ed9a95e --- /dev/null +++ b/client/src/components/Journey/JourneyDayRecordingButton.tsx @@ -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 ( + + + + ); + } + + if (!canTakeOver && !needsConnection) return null; + const label = canTakeOver ? t('journey.recording.takeOver') : t('dawarich.error.offline'); + return ( + + + + ); +} diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index 181dbf04f..cdd344c99 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -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( + + ); + + 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', () => { diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 4c4615ea5..e1f8c7f17 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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 diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index 7ab9f1993..a069be3c9 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -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 @@ -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( + , + ) + + 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() diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index b911c1523..115dd9d3f 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -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' }, + }) } } } diff --git a/client/src/components/Journey/JourneyRecordingDialog.test.tsx b/client/src/components/Journey/JourneyRecordingDialog.test.tsx new file mode 100644 index 000000000..d48e537a6 --- /dev/null +++ b/client/src/components/Journey/JourneyRecordingDialog.test.tsx @@ -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) { + const props = { onKept: vi.fn(), onRemoved: vi.fn(), onClose: vi.fn() }; + render(); + 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')); + }); +}); diff --git a/client/src/components/Journey/JourneyRecordingDialog.tsx b/client/src/components/Journey/JourneyRecordingDialog.tsx new file mode 100644 index 000000000..fea31f6be --- /dev/null +++ b/client/src/components/Journey/JourneyRecordingDialog.tsx @@ -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 ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + eyebrow={day.preview?.journey.title ?? journeyTitle} + title={title} + sub={t('journey.recording.sub')} + subWraps + /> + } + footer={ + + + + } + > + + + ); +} diff --git a/client/src/components/Journey/JourneyRecordingReview.tsx b/client/src/components/Journey/JourneyRecordingReview.tsx new file mode 100644 index 000000000..63edd7cae --- /dev/null +++ b/client/src/components/Journey/JourneyRecordingReview.tsx @@ -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; + +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 ( +

+

+ ); +} + +/** 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 ( + + ); +} + +/** 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 ( +
+ +
+ + {stats.firstAt && stats.lastAt && ( + + )} + {t('journey.recording.pieces', { count: stats.segmentCount })} + {stats.gapCount > 0 && ( + + + {t('journey.recording.gaps', { count: stats.gapCount })} + + + )} + {stats.modes.map((mode) => ( + {modeLabel(mode, t)} + ))} + + + + {t(preview.sourceKind === 'tracks' ? 'journey.recording.sourceTracks' : 'journey.recording.sourcePoints')} + + + +
+
+ ); +} + +/** 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 && {t('journey.recording.changedSincePreview')}} + {preview.truncated && {t('journey.recording.truncated')}} + {preview.provisional && {t('journey.recording.provisional')}} + {preview.sameAsStored && {t('journey.recording.same')}} + {stored && !preview.sameAsStored && ( + + {t('journey.recording.differs', { + date: new Date(stored.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'long' }), + distance: formatDistance(stored.stats.distanceMeters / 1000, unit), + })} + + )} + {preview.audience > 0 && ( +

+

+ )} + + ); +} + +/** 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 ( +

+

+ ); + } + if (day.error) { + return ( +
+ + {day.error} + {stored && ` ${t('journey.recording.keptStays')}`} + + + + +
+ ); + } + if (!preview) return null; + if (preview.contentHash === null) return {t('journey.recording.empty')}; + return ( +
+ + +
+ ); +} + +/** + * 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 ( + <> + + day.chooseZone(Number(value))} + options={day.zoneOptions} + disabled={day.loading} + /> +

+ {t('journey.recording.zoneHint')} +

+
+ + + + + ); +} + +/** + * 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 && ( + setConfirming(true)} + label={t('journey.recording.remove')} + disabled={busy !== null} + /> + )} + + {t('common.cancel')} + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + onRemove(); + }} + title={t('journey.recording.removeTitle')} + message={t('journey.recording.removeMessage')} + confirmLabel={t('journey.recording.remove')} + /> + + ); +} diff --git a/client/src/components/Journey/journeyRecordingModel.test.ts b/client/src/components/Journey/journeyRecordingModel.test.ts new file mode 100644 index 000000000..6c5c3a0e8 --- /dev/null +++ b/client/src/components/Journey/journeyRecordingModel.test.ts @@ -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: [] }); + }); +}); diff --git a/client/src/components/Journey/journeyRecordingModel.ts b/client/src/components/Journey/journeyRecordingModel.ts new file mode 100644 index 000000000..f32c2adf2 --- /dev/null +++ b/client/src/components/Journey/journeyRecordingModel.ts @@ -0,0 +1,154 @@ +import { OnlineOnlyError } from '../../repo/onlineOnly'; + +type Translate = (key: string, params?: Record) => 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 }>, + 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 }; +} diff --git a/client/src/components/Journey/useJourneyRecordingDay.test.ts b/client/src/components/Journey/useJourneyRecordingDay.test.ts new file mode 100644 index 000000000..246475952 --- /dev/null +++ b/client/src/components/Journey/useJourneyRecordingDay.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Journey/useJourneyRecordingDay.ts b/client/src/components/Journey/useJourneyRecordingDay.ts new file mode 100644 index 000000000..02d56d1e3 --- /dev/null +++ b/client/src/components/Journey/useJourneyRecordingDay.ts @@ -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) { + 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 }; diff --git a/client/src/components/Journey/useJourneyRecordings.test.ts b/client/src/components/Journey/useJourneyRecordings.test.ts new file mode 100644 index 000000000..51f6043c8 --- /dev/null +++ b/client/src/components/Journey/useJourneyRecordings.test.ts @@ -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 Promise>() })); +vi.mock('../../sync/useServerPing', () => ({ + useServerPing: (event: string, enabled: boolean, reload: () => Promise) => { + 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(); + }); +}); diff --git a/client/src/components/Journey/useJourneyRecordings.ts b/client/src/components/Journey/useJourneyRecordings.ts new file mode 100644 index 000000000..6e973bfc1 --- /dev/null +++ b/client/src/components/Journey/useJourneyRecordings.ts @@ -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; + /** 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 }; diff --git a/client/src/hooks/useDawarichJournalTrail.test.ts b/client/src/hooks/useDawarichJournalTrail.test.ts index 508849582..b76bedc37 100644 --- a/client/src/hooks/useDawarichJournalTrail.test.ts +++ b/client/src/hooks/useDawarichJournalTrail.test.ts @@ -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, diff --git a/client/src/hooks/useDawarichJournalTrail.ts b/client/src/hooks/useDawarichJournalTrail.ts index a16068c24..af2c473f1 100644 --- a/client/src/hooks/useDawarichJournalTrail.ts +++ b/client/src/hooks/useDawarichJournalTrail.ts @@ -71,6 +71,7 @@ export function useDawarichJournalTrail( name: segment.date, color: segment.color, points: segment.points, + kind: 'dawarich_live', })), ) setFetchedAt(track.fetchedAt) diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 07d0e0ffc..2cbc9f32e 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -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 {t('journey.skeletons.hide')} + {/* 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)) && ( + { setShowActionMenu(false); recordings.open(entries[activeIndex].entry_date) }} + /> + )} {canEditJourney && ( )} + {recordings.dialog && } + {/* Journey settings */} {showSettings && ( +
+ + + + } + title={title} + subtitle={day.preview?.journey.title ?? journeyTitle} + onClose={onClose} + closeLabel={t('common.close')} + /> +
+

{t('journey.recording.sub')}

+ +
+
+ +
+
+ + ); +} diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 3ec87cbda..78bb838a9 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -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
{entries.length} {t('journey.synced.places', { count: entries.length })} + 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 && } + {/* Add Trip Dialog */} {showAddTrip && current && ( (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, diff --git a/client/src/pages/journeyDetail/useJourneyDetailRecordings.test.ts b/client/src/pages/journeyDetail/useJourneyDetailRecordings.test.ts new file mode 100644 index 000000000..9ae960097 --- /dev/null +++ b/client/src/pages/journeyDetail/useJourneyDetailRecordings.test.ts @@ -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 => ({ + 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[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); + }); +}); diff --git a/client/src/pages/journeyDetail/useJourneyDetailRecordings.ts b/client/src/pages/journeyDetail/useJourneyDetailRecordings.ts new file mode 100644 index 000000000..78b54f66f --- /dev/null +++ b/client/src/pages/journeyDetail/useJourneyDetailRecordings.ts @@ -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, + }; +} diff --git a/client/src/repo/journeyRecordingsRepo.test.ts b/client/src/repo/journeyRecordingsRepo.test.ts new file mode 100644 index 000000000..edfad5c7e --- /dev/null +++ b/client/src/repo/journeyRecordingsRepo.test.ts @@ -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(); + }); +}); diff --git a/client/src/repo/journeyRecordingsRepo.ts b/client/src/repo/journeyRecordingsRepo.ts new file mode 100644 index 000000000..dea455035 --- /dev/null +++ b/client/src/repo/journeyRecordingsRepo.ts @@ -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 { + requireOnline(); + return journeyRecordedDaysResponseSchema.parse(await journeyRecordingsApi.list(journeyId, signal)).days; + }, + + async preview( + journeyId: number, + body: JourneyRecordingPreviewRequest, + signal?: AbortSignal + ): Promise { + requireOnline(); + return journeyRecordingPreviewSchema.parse(await journeyRecordingsApi.preview(journeyId, body, signal)); + }, + + async save(journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise { + requireOnline(); + return journeyRecordingSaveResultSchema.parse(await journeyRecordingsApi.save(journeyId, date, body)); + }, + + async remove(journeyId: number, date: string): Promise { + requireOnline(); + await journeyRecordingsApi.remove(journeyId, date); + }, +}; diff --git a/client/tests/helpers/journeyRecording.ts b/client/tests/helpers/journeyRecording.ts new file mode 100644 index 000000000..655ce9cfb --- /dev/null +++ b/client/tests/helpers/journeyRecording.ts @@ -0,0 +1,64 @@ +import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared'; + +/** A walk across Venice on the 14th, the way the server answers it. */ +export const WALK_SEGMENT = { + points: [ + [45.434, 12.3388], + [45.4364, 12.3218], + ] as Array<[number, number]>, + mode: 'walking', + startedAt: '2026-05-14T09:00:00+02:00', + endedAt: '2026-05-14T09:40:00+02:00', + distanceMeters: 1500, +}; + +export function buildRecordedDay(over: Partial = {}): JourneyRecordedDay { + return { + id: 3, + date: '2026-05-14', + source: 'dawarich', + sourceKind: 'tracks', + utcOffsetMinutes: 120, + window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' }, + segments: [WALK_SEGMENT], + stats: { + distanceMeters: 1500, + pointCount: 2, + segmentCount: 1, + gapCount: 0, + modes: ['walking'], + firstAt: WALK_SEGMENT.startedAt, + lastAt: WALK_SEGMENT.endedAt, + }, + truncated: false, + provisional: false, + fetchedAt: '2026-05-15T07:00:00.000Z', + contentHash: 'a'.repeat(64), + importedBy: { id: 1, username: 'marta' }, + savedAt: '2026-05-15T07:00:01.000Z', + ...over, + }; +} + +export function buildRecordingPreview(over: Partial = {}): JourneyRecordingPreview { + const day = buildRecordedDay(); + return { + journey: { id: 7, title: 'Venice' }, + date: day.date, + utcOffsetMinutes: 120, + offsetSource: 'source', + sourceOffsetMinutes: 120, + window: day.window, + sourceKind: 'tracks', + segments: day.segments, + stats: day.stats, + truncated: false, + provisional: false, + fetchedAt: day.fetchedAt, + contentHash: 'a'.repeat(64), + stored: null, + sameAsStored: false, + audience: 0, + ...over, + }; +} diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index 016362bbf..0546dd35d 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -45,6 +45,7 @@ "JourneyEntries": "journey", "JourneyEntryPhotos": "journey", "JourneyPhotos": "journey", + "JourneyRecordedDays": "journey", "JourneyShareTokens": "journey", "JourneyTrips": "journey", "Journeys": "journey", diff --git a/server/src/db/entities/JourneyRecordedDays.entity.ts b/server/src/db/entities/JourneyRecordedDays.entity.ts new file mode 100644 index 000000000..225949eaf --- /dev/null +++ b/server/src/db/entities/JourneyRecordedDays.entity.ts @@ -0,0 +1,60 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyRecordedDaysRepository } from '../repositories/JourneyRecordedDays.repository'; +import { DbTimestampType } from '../types'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyRecordedDays { + [EntityRepositoryType]?: JourneyRecordedDaysRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + local_date!: string; + source: string & Opt = 'dawarich'; + source_kind!: string; + utc_offset_minutes!: number; + window_from!: string; + window_to!: string; + segments!: string; + point_count!: number; + distance_m?: number | null; + content_hash!: string; + truncated: number & Opt = 0; + fetched_at!: string; + imported_by?: number | null; + created_at!: string & Opt; + updated_at!: string & Opt; + importedByRef?: Ref | null; +} + +export const JourneyRecordedDaysSchema = defineEntity({ + class: JourneyRecordedDays, + repository: () => JourneyRecordedDaysRepository, + uniques: [ + { + name: 'idx_journey_recorded_days_day', + properties: ['journey_id', 'local_date', 'source'], + }, + ], + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden(), + journey_id: p.integer().persist(false), + local_date: p.text(), + source: p.text().default('dawarich'), + source_kind: p.text(), + utc_offset_minutes: p.integer(), + window_from: p.text(), + window_to: p.text(), + segments: p.text(), + point_count: p.integer(), + distance_m: p.double().nullable(), + content_hash: p.text(), + truncated: p.integer().default(0), + fetched_at: p.text(), + imported_by: p.integer().nullable().persist(false).index('idx_journey_recorded_days_imported_by'), + created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`), + importedByRef: () => p.manyToOne(Users).ref().joinColumn('imported_by').nullable().hidden().index('idx_journey_recorded_days_imported_by'), + }, +}); diff --git a/server/src/db/entities/Journeys.entity.ts b/server/src/db/entities/Journeys.entity.ts index 0af94f20a..536a29fc8 100644 --- a/server/src/db/entities/Journeys.entity.ts +++ b/server/src/db/entities/Journeys.entity.ts @@ -4,6 +4,7 @@ import { JourneyBooks } from './JourneyBooks.entity'; import { JourneyContributors } from './JourneyContributors.entity'; import { JourneyEntries } from './JourneyEntries.entity'; import { JourneyPhotos } from './JourneyPhotos.entity'; +import { JourneyRecordedDays } from './JourneyRecordedDays.entity'; import { JourneyShareTokens } from './JourneyShareTokens.entity'; import { JourneyTrips } from './JourneyTrips.entity'; import { TripJournalLinks } from './TripJournalLinks.entity'; @@ -31,6 +32,7 @@ export class Journeys { journey_contributors_collection = new Collection(this); journey_entries_collection = new Collection(this); journey_photos_collection = new Collection(this); + journey_recorded_days_collection = new Collection(this); journey_share_tokens: Ref | null = null; journey_trips_collection = new Collection(this); trip_journal_links_collection = new Collection(this); @@ -60,6 +62,7 @@ export const JourneysSchema = defineEntity({ journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('journey').hidden(), journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('journey').hidden(), journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(), + journey_recorded_days_collection: () => p.oneToMany(JourneyRecordedDays).mappedBy('journey').hidden(), journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(), journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(), trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(), diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts index 13b88a051..9f8708e53 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -30,6 +30,7 @@ import { InviteTokens } from './InviteTokens.entity'; import { JourneyBooks } from './JourneyBooks.entity'; import { JourneyContributors } from './JourneyContributors.entity'; import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyRecordedDays } from './JourneyRecordedDays.entity'; import { JourneyShareTokens } from './JourneyShareTokens.entity'; import { Journeys } from './Journeys.entity'; import { McpTokens } from './McpTokens.entity'; @@ -158,6 +159,7 @@ export class Users { journey_books_collection1 = new Collection(this); journey_contributors_collection = new Collection(this); journey_entries_collection = new Collection(this); + journey_recorded_days_collection = new Collection(this); journey_share_tokens_collection = new Collection(this); journeys_collection = new Collection(this); mcp_tokens_collection = new Collection(this); @@ -308,6 +310,7 @@ export const UsersSchema = defineEntity({ journey_books_collection1: () => p.oneToMany(JourneyBooks).mappedBy('createdByRef').hidden(), journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('user').hidden(), journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('author').hidden(), + journey_recorded_days_collection: () => p.oneToMany(JourneyRecordedDays).mappedBy('importedByRef').hidden(), journey_share_tokens_collection: () => p.oneToMany(JourneyShareTokens).mappedBy('createdByRef').hidden(), journeys_collection: () => p.oneToMany(Journeys).mappedBy('user').hidden(), mcp_tokens_collection: () => p.oneToMany(McpTokens).mappedBy('user').hidden(), diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index f628f3b7f..618716d0b 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -45,6 +45,7 @@ import { JourneyContributorsSchema } from './JourneyContributors.entity'; import { JourneyEntriesSchema } from './JourneyEntries.entity'; import { JourneyEntryPhotosSchema } from './JourneyEntryPhotos.entity'; import { JourneyPhotosSchema } from './JourneyPhotos.entity'; +import { JourneyRecordedDaysSchema } from './JourneyRecordedDays.entity'; import { JourneyShareTokensSchema } from './JourneyShareTokens.entity'; import { JourneyTripsSchema } from './JourneyTrips.entity'; import { JourneysSchema } from './Journeys.entity'; @@ -196,6 +197,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ JourneyEntriesSchema, JourneyEntryPhotosSchema, JourneyPhotosSchema, + JourneyRecordedDaysSchema, JourneyShareTokensSchema, JourneyTripsSchema, JourneysSchema, diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 7560a9457..e2bfd4bd8 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -1,6 +1,7 @@ import type { JsonColumn } from '../utils/json-column'; import { FERRATAS_RESULT_CAP, + dawarichTrackSegmentSchema, ferrataSchema, hikingPreferencesSchema, obstacleSnapshotSchema, @@ -15,6 +16,7 @@ import { tourSourceHintChangeSchema, tourWaypointSchema, trackMarksSchema, + type DawarichTrackSegment, type Ferrata, type GeometryPoint, type HikingPreferences, @@ -315,3 +317,14 @@ export const TOUR_SOURCE_HINT_CHANGES: JsonColumn = { schema: z.array(tourSourceHintChangeSchema), fallback: () => [], }; + +/** + * JRN-01: the pieces of a day's recorded route a journey keeps, each with its + * mode and times. A broken row reads as none, so a day never draws a line it + * cannot vouch for. + */ +export const JOURNEY_RECORDED_DAY_SEGMENTS: JsonColumn = { + column: 'journey_recorded_days.segments', + schema: z.array(dawarichTrackSegmentSchema), + fallback: () => [], +}; diff --git a/server/src/db/kysely/JourneyRecordedDays.table.ts b/server/src/db/kysely/JourneyRecordedDays.table.ts new file mode 100644 index 000000000..d8fb5d2e0 --- /dev/null +++ b/server/src/db/kysely/JourneyRecordedDays.table.ts @@ -0,0 +1,23 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface JourneyRecordedDaysTable { + id: InsertOptional; + journey_id: number; + local_date: string; + source: InsertOptional; + source_kind: string; + utc_offset_minutes: number; + window_from: string; + window_to: string; + segments: string; + point_count: number; + distance_m: InsertOptional; + content_hash: string; + truncated: InsertOptional; + fetched_at: string; + imported_by: InsertOptional; + created_at: InsertOptional; + updated_at: InsertOptional; +} diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index c5b70a3da..31b6b29ac 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -48,6 +48,7 @@ import type { JourneyContributorsTable } from './JourneyContributors.table'; import type { JourneyEntriesTable } from './JourneyEntries.table'; import type { JourneyEntryPhotosTable } from './JourneyEntryPhotos.table'; import type { JourneyPhotosTable } from './JourneyPhotos.table'; +import type { JourneyRecordedDaysTable } from './JourneyRecordedDays.table'; import type { JourneyShareTokensTable } from './JourneyShareTokens.table'; import type { JourneyTripsTable } from './JourneyTrips.table'; import type { JourneysTable } from './Journeys.table'; @@ -205,6 +206,7 @@ export interface DB { journey_entries: JourneyEntriesTable; journey_entry_photos: JourneyEntryPhotosTable; journey_photos: JourneyPhotosTable; + journey_recorded_days: JourneyRecordedDaysTable; journey_share_tokens: JourneyShareTokensTable; journey_trips: JourneyTripsTable; journeys: JourneysTable; diff --git a/server/src/db/migrations/Migration20200101046160_a_journey_keeps_its_recorded_days.ts b/server/src/db/migrations/Migration20200101046160_a_journey_keeps_its_recorded_days.ts new file mode 100644 index 000000000..5a4463dd0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046160_a_journey_keeps_its_recorded_days.ts @@ -0,0 +1,48 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * JRN-01: a journey keeps the route recorded on one of its days, taken over + * from Dawarich on purpose, so the day stays drawn once the connection is + * gone. + * + * One row per journey, local day and source. `segments` holds the recording + * as it was taken (JSON: the thinned pieces with their mode and times, a gap + * between two pieces kept as a gap), `utc_offset_minutes` the zone the day was + * cut in, `window_from`/`window_to` the instants that day ran from and to, + * `fetched_at` when the source answered and `content_hash` what it answered, + * so taking the same day over again replaces nothing that did not change. + * `imported_by` names whose recording it is; the row outlives that account. + */ +export class Migration20200101046160_a_journey_keeps_its_recorded_days extends Migration { + override name = 'Migration20200101046160_a_journey_keeps_its_recorded_days'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_recorded_days ( + id INTEGER PRIMARY KEY, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + local_date TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'dawarich', + source_kind TEXT NOT NULL, + utc_offset_minutes INTEGER NOT NULL, + window_from TEXT NOT NULL, + window_to TEXT NOT NULL, + segments TEXT NOT NULL, + point_count INTEGER NOT NULL, + distance_m REAL, + content_hash TEXT NOT NULL, + truncated INTEGER NOT NULL DEFAULT 0, + fetched_at TEXT NOT NULL, + imported_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + this.addSql( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_recorded_days_day ON journey_recorded_days(journey_id, local_date, source)', + ); + this.addSql( + 'CREATE INDEX IF NOT EXISTS idx_journey_recorded_days_imported_by ON journey_recorded_days(imported_by)', + ); + } +} diff --git a/server/src/db/repositories/JourneyRecordedDays.repository.ts b/server/src/db/repositories/JourneyRecordedDays.repository.ts new file mode 100644 index 000000000..54bd745ae --- /dev/null +++ b/server/src/db/repositories/JourneyRecordedDays.repository.ts @@ -0,0 +1,125 @@ +import type { JourneyRecordedDays } from '../entities/JourneyRecordedDays.entity'; +import type { DB } from '../kysely/db'; +import { dbNow } from '../types'; +import { TrekRepository } from './_shared/trek-repository'; + +type RecordedDaysDB = Pick; + +/** A day as it is taken over; `segments` is its JSON text. */ +export interface JourneyRecordedDayWrite { + journey_id: number; + local_date: string; + source: string; + source_kind: string; + utc_offset_minutes: number; + window_from: string; + window_to: string; + segments: string; + point_count: number; + distance_m: number | null; + content_hash: string; + truncated: number; + fetched_at: string; + imported_by: number | null; +} + +/** A kept day as it is read back, with the name of whose recording it is. */ +export interface JourneyRecordedDayRow extends JourneyRecordedDayWrite { + id: number; + imported_by_username: string | null; + created_at: string; + updated_at: string; +} + +/** + * `journey_recorded_days` (JRN-01): the route recorded on a day of a journey, + * taken over on purpose, one version per journey, local day and source. A + * day goes with its journey; it outlives the account that took it over. + */ +export class JourneyRecordedDaysRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + private selectRows() { + return this.db() + .selectFrom('journey_recorded_days as d') + .leftJoin('users as u', 'u.id', 'd.imported_by') + .select([ + 'd.id', + 'd.journey_id', + 'd.local_date', + 'd.source', + 'd.source_kind', + 'd.utc_offset_minutes', + 'd.window_from', + 'd.window_to', + 'd.segments', + 'd.point_count', + 'd.distance_m', + 'd.content_hash', + 'd.truncated', + 'd.fetched_at', + 'd.imported_by', + 'u.username as imported_by_username', + 'd.created_at', + 'd.updated_at', + ]); + } + + /** The days a journey keeps, earliest first. */ + async listForJourney(journeyId: number): Promise { + return await this.selectRows().where('d.journey_id', '=', journeyId).orderBy('d.local_date', 'asc').execute(); + } + + /** The version a journey keeps for one day of one source; undefined while it keeps none. */ + async findDay(journeyId: number, localDate: string, source: string): Promise { + return await this.selectRows() + .where('d.journey_id', '=', journeyId) + .where('d.local_date', '=', localDate) + .where('d.source', '=', source) + .executeTakeFirst(); + } + + /** + * Keeps a day, replacing the version kept for the same journey, day and + * source in the one statement, and answers its id. A replaced day keeps its + * id and the time it was first taken over. + */ + async upsertDay(day: JourneyRecordedDayWrite): Promise { + const now = dbNow(); + const written = await this.db() + .insertInto('journey_recorded_days') + .values({ ...day, created_at: now, updated_at: now }) + .onConflict((oc) => + oc.columns(['journey_id', 'local_date', 'source']).doUpdateSet({ + source_kind: day.source_kind, + utc_offset_minutes: day.utc_offset_minutes, + window_from: day.window_from, + window_to: day.window_to, + segments: day.segments, + point_count: day.point_count, + distance_m: day.distance_m, + content_hash: day.content_hash, + truncated: day.truncated, + fetched_at: day.fetched_at, + imported_by: day.imported_by, + updated_at: now, + }), + ) + .returning('id') + .executeTakeFirstOrThrow(); + return written.id; + } + + /** Drops the day a journey keeps for one source; false when it kept none. */ + async deleteDay(journeyId: number, localDate: string, source: string): Promise { + const result = await this.db() + .deleteFrom('journey_recorded_days') + .where('journey_id', '=', journeyId) + .where('local_date', '=', localDate) + .where('source', '=', source) + .executeTakeFirst(); + return Number(result.numDeletedRows) > 0; + } +} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 93e880b87..76c183de7 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -50,6 +50,7 @@ import { HealthModule } from './health/health.module'; import { HelpModule } from './help/help.module'; import { AirtrailModule } from './integrations/airtrail.module'; import { DawarichModule } from './integrations/dawarich.module'; +import { JourneyRecordingsModule } from './journey-recordings/journey-recordings.module'; import { JourneyAccessModule } from './journey/journey-access.module'; import { JourneyModule } from './journey/journey.module'; import { TripJournalLinkModule } from './journey/trip-journal-link.module'; @@ -171,6 +172,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; AirtrailModule, DawarichModule, JourneyModule, + JourneyRecordingsModule, TripJournalLinkModule, CollectionsModule, ShareModule, diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts index 7dfa8b069..dc67decb2 100644 --- a/server/src/nest/integrations/dawarich-tracks.service.ts +++ b/server/src/nest/integrations/dawarich-tracks.service.ts @@ -1,19 +1,38 @@ import { Trips } from '../../db/entities/Trips.entity'; import { TripsRepository } from '../../db/repositories/Trips.repository'; -import { DawarichClient, type DawarichCreds } from './dawarich.client'; -import { bucketPointsByDay, bucketTracksByDay, countPoints, offsetMinutesOf } from './dawarich.helpers'; +import { DomainError } from '../common/domain-error'; +import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client'; +import { RECORDING_GAP, bucketPointsByDay, bucketTracksByDay, countPoints, offsetMinutesOf } from './dawarich.helpers'; import { DawarichService } from './dawarich.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable } from '@nestjs/common'; -import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack } from '@trek/shared'; +import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack, type DawarichTrackSegment } from '@trek/shared'; + +/** One local day of recording, read for a journey to keep (JRN-01). */ +export interface RecordedDayFetch { + segments: DawarichTrackSegment[]; + sourceKind: 'tracks' | 'points'; + /** The zone the day was cut in, minutes east of UTC. */ + utcOffsetMinutes: number; + offsetSource: 'source' | 'chosen'; + /** The zone Dawarich records in, where its answer shows one. */ + sourceOffsetMinutes: number | null; + window: { from: string; to: string }; + fetchedAt: string; + truncated: boolean; +} + +const HOUR_MS = 3_600_000; /** - * The recorded route for a window, fetched on demand and never stored. + * The recorded route for a window, fetched on demand and never stored here. * * That is the point of the whole feature and the one rule it must not break: * Dawarich owns the location archive. TREK draws the part of it somebody is * looking at right now and forgets it again. There is no table here, no - * `route_geometry` written to a place, no background import. + * `route_geometry` written to a place, no background import. The one copy is + * a day a traveller takes over into a journey on purpose (`fetchDay`, JRN-01), + * and the journey keeps that, not this service. * * What there is instead is a short in-memory cache, because "update the line * while the map is open" and "ask an instance for a month of GPS every few @@ -161,6 +180,65 @@ export class DawarichTracksService { }; } + /** + * One local day of recording, read fresh past the cache: what a journey keeps + * has to be what the instance says now, not what the map saw a minute ago. + * + * The day is cut in Dawarich's own zone when `useSourceOffset` asks for it and + * the answer reveals one (track times carry it, points do not), else in the + * zone given. The request spans every zone there is around the date, so the + * day is the same day whichever zone cuts it. A gap in the recording stays a + * gap. Refuses with a DomainError the controller and the MCP tool pass on: + * 409 `not_connected`, or the instance's own reason. + */ + async fetchDay( + userId: number, + date: string, + zone: { offsetMinutes: number; useSourceOffset: boolean }, + ): Promise { + const creds = await this.dawarich.getCredentials(userId); + if (!creds) throw new DomainError(409, 'Dawarich is not connected', { details: { code: 'not_connected' } }); + + const dayStart = Date.parse(`${date}T00:00:00Z`); + const from = new Date(dayStart - 14 * HOUR_MS); + const to = new Date(dayStart + 36 * HOUR_MS); + const capabilities = await this.dawarich.getCapabilities(userId); + + if (capabilities?.tracks !== false) { + try { + const { features, truncated } = await this.client.listTracks(creds, from, to); + const sourceOffset = sourceOffsetOf(features[0]?.properties?.start_at); + const offset = zoneOf(zone, sourceOffset); + const day = bucketTracksByDay(features, { + maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY, + offsetMinutes: offset.minutes, + }).find((d) => d.date === date); + if (day && day.segments.length > 0) { + return recordedDay(date, day.segments, 'tracks', offset, sourceOffset, truncated); + } + } catch { + // Points next, as for the overlay: an instance that cannot serve tracks + // can usually still serve what they are built from. + } + } + + const offset = zoneOf(zone, null); + try { + const { points, truncated } = await this.client.listPoints(creds, from, to); + const day = bucketPointsByDay(points, offset.minutes, { + maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY, + gaps: RECORDING_GAP, + }).find((d) => d.date === date); + return recordedDay(date, day?.segments ?? [], 'points', offset, null, truncated); + } catch (err) { + if (err instanceof DawarichError) { + const status = err.code === 'unauthorized' || err.code === 'forbidden' ? 400 : 502; + throw new DomainError(status, err.message, { details: { code: err.code } }); + } + throw err; + } + } + private remember(key: string, value: DawarichTrack): void { if (this.cache.size >= DawarichTracksService.MAX_ENTRIES) { const oldest = this.cache.keys().next().value; @@ -177,6 +255,42 @@ export class DawarichTracksService { } } +/** The zone a Dawarich time names; null when it names none. */ +function sourceOffsetOf(value: unknown): number | null { + if (typeof value !== 'string' || !/(Z|[+-]\d{2}:?\d{2})$/.test(value)) return null; + return offsetMinutesOf(value); +} + +function zoneOf( + zone: { offsetMinutes: number; useSourceOffset: boolean }, + sourceOffset: number | null, +): { minutes: number; source: 'source' | 'chosen' } { + return zone.useSourceOffset && sourceOffset !== null + ? { minutes: sourceOffset, source: 'source' } + : { minutes: zone.offsetMinutes, source: 'chosen' }; +} + +function recordedDay( + date: string, + segments: DawarichTrackSegment[], + sourceKind: 'tracks' | 'points', + offset: { minutes: number; source: 'source' | 'chosen' }, + sourceOffsetMinutes: number | null, + truncated: boolean, +): RecordedDayFetch { + const from = Date.parse(`${date}T00:00:00Z`) - offset.minutes * 60_000; + return { + segments, + sourceKind, + utcOffsetMinutes: offset.minutes, + offsetSource: offset.source, + sourceOffsetMinutes, + window: { from: new Date(from).toISOString(), to: new Date(from + 24 * HOUR_MS).toISOString() }, + fetchedAt: new Date().toISOString(), + truncated, + }; +} + function emptyTrack(): DawarichTrack { return { days: [], diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts index c34d2331d..0ace9ed48 100644 --- a/server/src/nest/integrations/dawarich.helpers.ts +++ b/server/src/nest/integrations/dawarich.helpers.ts @@ -4,7 +4,7 @@ import type { DawarichVisitRaw, DawarichVisitedCountry, } from './dawarich.client'; -import type { DawarichTrackDay, DawarichTrackSegment } from '@trek/shared'; +import { RECORDING_GAP_METERS, type DawarichTrackDay, type DawarichTrackSegment } from '@trek/shared'; import { createHash } from 'crypto'; @@ -41,9 +41,9 @@ export function offsetMinutesOf(iso: string): number { return sign * (Number(match[2]) * 60 + Number(match[3])); } -/** Local wall-clock milliseconds — the instant shifted into the string's own offset. */ -function localMs(iso: string): number { - return Date.parse(iso) + offsetMinutesOf(iso) * 60_000; +/** Local wall-clock milliseconds — the instant shifted into the string's own offset, or into `offset` when given. */ +function localMs(iso: string, offset?: number): number { + return Date.parse(iso) + (offset ?? offsetMinutesOf(iso)) * 60_000; } /** `YYYY-MM-DD` of a local wall-clock millisecond value. */ @@ -307,10 +307,61 @@ interface BucketOptions { maxPointsPerDay: number; /** Simplification tolerance in degrees. ~5e-5 is roughly five metres. */ epsilon: number; + /** + * Cut a day where the recording has a gap instead of drawing across it, and + * never join a day to the one before. Off for the live overlay, which has + * always joined its points up; on for a day a journey keeps (JRN-01). + */ + gaps?: GapRule; + /** + * Cut tracks at midnight in this zone (minutes east of UTC) rather than in + * the one their own times carry. + */ + offsetMinutes?: number; } const DEFAULT_BUCKET: BucketOptions = { maxPointsPerDay: 600, epsilon: 0.00005 }; +/** When two recorded points are too far apart, in time and in space, to have been one movement. */ +interface GapRule { + seconds: number; + meters: number; +} + +/** + * Silence on its own is no gap: a phone that records only when it moves sends + * nothing for an hour at a café table and then goes on from the same spot. + * Silence that ends somewhere else is one, and the line between the two ends + * would be a way nobody can say was travelled. + */ +export const RECORDING_GAP: GapRule = { seconds: 15 * 60, meters: RECORDING_GAP_METERS }; + +interface TimedPoint { + lat: number; + lng: number; + ts: number; +} + +/** Points in time order → the runs between the gaps of the recording. */ +export function splitAtGaps(points: T[], rule: GapRule): T[][] { + const runs: T[][] = []; + let run: T[] = []; + for (const point of points) { + const last = run[run.length - 1]; + if ( + last && + point.ts - last.ts > rule.seconds && + distanceMeters(last.lat, last.lng, point.lat, point.lng) > rule.meters + ) { + runs.push(run); + run = []; + } + run.push(point); + } + if (run.length > 0) runs.push(run); + return runs; +} + /** * Recorded points → one entry per local calendar day. * @@ -324,8 +375,8 @@ export function bucketPointsByDay( offsetMinutes: number, options: Partial = {}, ): DawarichTrackDay[] { - const { maxPointsPerDay, epsilon } = { ...DEFAULT_BUCKET, ...options }; - const byDay = new Map>(); + const { maxPointsPerDay, epsilon, gaps } = { ...DEFAULT_BUCKET, ...options }; + const byDay = new Map(); for (const point of points) { const lat = toNumber(point.latitude); @@ -346,6 +397,11 @@ export function bucketPointsByDay( for (let d = 0; d < dates.length; d++) { const date = dates[d]!; const own = byDay.get(date)!.sort((a, b) => a.ts - b.ts); + if (gaps) { + const segments = runsAsSegments(splitAtGaps(own, gaps), own.length, { maxPointsPerDay, epsilon }); + if (segments.length > 0) days.push({ date, segments }); + continue; + } // Stitch the previous day's last point on, so consecutive days join up // rather than showing a jump the traveller never made. const previous = d > 0 ? byDay.get(dates[d - 1]!) : undefined; @@ -377,6 +433,40 @@ export function bucketPointsByDay( return days; } +/** + * The runs of one day as drawable segments, the day's point budget shared out + * by how many points each run brought. A run that thins to a single point is + * a fix, not a way, and is left out. + */ +function runsAsSegments( + runs: TimedPoint[][], + total: number, + { maxPointsPerDay, epsilon }: Pick, +): DawarichTrackSegment[] { + const segments: DawarichTrackSegment[] = []; + for (const run of runs) { + const budget = Math.max(2, Math.floor((maxPointsPerDay * run.length) / total)); + const line = capPoints( + simplify( + run.map((p): [number, number] => [p.lat, p.lng]), + epsilon, + ), + budget, + ); + const first = run[0]; + const last = run[run.length - 1]; + if (line.length < 2 || !first || !last) continue; + segments.push({ + points: line, + mode: null, + startedAt: new Date(first.ts * 1000).toISOString(), + endedAt: new Date(last.ts * 1000).toISOString(), + distanceMeters: null, + }); + } + return segments; +} + /** * Dawarich tracks → one entry per local calendar day. * @@ -391,7 +481,7 @@ export function bucketTracksByDay( features: DawarichTrackFeature[], options: Partial = {}, ): DawarichTrackDay[] { - const { maxPointsPerDay, epsilon } = { ...DEFAULT_BUCKET, ...options }; + const { maxPointsPerDay, epsilon, offsetMinutes } = { ...DEFAULT_BUCKET, ...options }; const byDay = new Map(); for (const feature of features) { @@ -406,7 +496,7 @@ export function bucketTracksByDay( const mode = typeof props?.dominant_mode === 'string' ? props.dominant_mode : null; const distance = toNumber(props?.distance); - for (const piece of splitAtLocalMidnight(points, startedAt, endedAt)) { + for (const piece of splitAtLocalMidnight(points, startedAt, endedAt, offsetMinutes)) { const line = capPoints(simplify(piece.points, epsilon), maxPointsPerDay); if (line.length < 2) continue; const segment: DawarichTrackSegment = { @@ -442,9 +532,14 @@ interface TrackPiece { whole: boolean; } -function splitAtLocalMidnight(points: Array<[number, number]>, startedAt: string, endedAt: string): TrackPiece[] { - const startLocal = localMs(startedAt); - const endLocal = localMs(endedAt); +function splitAtLocalMidnight( + points: Array<[number, number]>, + startedAt: string, + endedAt: string, + zone?: number, +): TrackPiece[] { + const startLocal = localMs(startedAt, zone); + const endLocal = localMs(endedAt, zone); const startDate = dateOfLocalMs(startLocal); const endDate = dateOfLocalMs(endLocal); @@ -468,7 +563,7 @@ function splitAtLocalMidnight(points: Array<[number, number]>, startedAt: string return [{ date: startDate, points, startedAt, endedAt, whole: true }]; } - const offset = offsetMinutesOf(startedAt) * 60_000; + const offset = (zone ?? offsetMinutesOf(startedAt)) * 60_000; const span = endLocal - startLocal; const pieces: TrackPiece[] = []; let cutIndex = 0; diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts index 8890e6336..e823b8427 100644 --- a/server/src/nest/integrations/dawarich.mcp.ts +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -202,7 +202,7 @@ export class DawarichMcp { @Tool({ name: 'get_dawarich_trip_track', description: - "Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. Nothing is stored in TREK — this is a live read of the recording.", + "Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. This is a live read of the recording and stores nothing; a day is kept in a journey only when the traveller confirms it through save_journey_recorded_day.", inputSchema: { tripId: idSchema, from: z diff --git a/server/src/nest/journey-recordings/journey-recordings.controller.ts b/server/src/nest/journey-recordings/journey-recordings.controller.ts new file mode 100644 index 000000000..bfc33faf5 --- /dev/null +++ b/server/src/nest/journey-recordings/journey-recordings.controller.ts @@ -0,0 +1,85 @@ +import { ADDON_IDS } from '../../addons'; +import type { User } from '../../types'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { JourneyRecordingPreviewDto, JourneyRecordingSaveDto } from './journey-recordings.dto'; +import { JourneyRecordingsService } from './journey-recordings.service'; +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpCode, + HttpException, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { + journeyRecordedDaysResponseSchema, + journeyRecordingDateSchema, + journeyRecordingPreviewSchema, + journeyRecordingSaveResultSchema, + successResponseSchema, +} from '@trek/shared'; + +/** + * The days of recorded route a journey keeps (JRN-01), at + * `/api/journeys/:id/recordings`. Reading them needs only access to the + * journey; previewing and keeping a day needs the right to edit it and the + * Dawarich addon, and reads the caller's own connection. + */ +@Controller('api/journeys/:id/recordings') +@UseGuards(AddonGuard, JwtAuthGuard) +@RequireAddon(ADDON_IDS.JOURNEY, 'Journey') +export class JourneyRecordingsController { + constructor(private readonly recordings: JourneyRecordingsService) {} + + @Get() + @ResponseContract(journeyRecordedDaysResponseSchema) + async list(@CurrentUser() user: User, @Param('id') id: string) { + return { days: await this.recordings.list(user.id, Number(id)) }; + } + + @Post('preview') + @HttpCode(200) + @ResponseContract(journeyRecordingPreviewSchema) + async preview(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyRecordingPreviewDto) { + return await this.recordings.preview(user.id, Number(id), body); + } + + @Put(':date') + @ResponseContract(journeyRecordingSaveResultSchema) + async save( + @CurrentUser() user: User, + @Param('id') id: string, + @Param('date') date: string, + @Body() body: JourneyRecordingSaveDto, + @Headers('x-socket-id') socketId?: string, + ) { + return await this.recordings.save(user.id, Number(id), dateParam(date), body, socketId); + } + + @Delete(':date') + @HttpCode(200) + @ResponseContract(successResponseSchema) + async remove( + @CurrentUser() user: User, + @Param('id') id: string, + @Param('date') date: string, + @Headers('x-socket-id') socketId?: string, + ) { + await this.recordings.remove(user.id, Number(id), dateParam(date), socketId); + return { success: true as const }; + } +} + +function dateParam(raw: string): string { + if (!journeyRecordingDateSchema.safeParse(raw).success) throw new HttpException({ error: 'Invalid date' }, 400); + return raw; +} diff --git a/server/src/nest/journey-recordings/journey-recordings.dto.ts b/server/src/nest/journey-recordings/journey-recordings.dto.ts new file mode 100644 index 000000000..aa012b20c --- /dev/null +++ b/server/src/nest/journey-recordings/journey-recordings.dto.ts @@ -0,0 +1,6 @@ +import { journeyRecordingPreviewRequestSchema, journeyRecordingSaveRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class JourneyRecordingPreviewDto extends createZodDto(journeyRecordingPreviewRequestSchema) {} +export class JourneyRecordingSaveDto extends createZodDto(journeyRecordingSaveRequestSchema) {} diff --git a/server/src/nest/journey-recordings/journey-recordings.mcp.ts b/server/src/nest/journey-recordings/journey-recordings.mcp.ts new file mode 100644 index 000000000..ddfb68852 --- /dev/null +++ b/server/src/nest/journey-recordings/journey-recordings.mcp.ts @@ -0,0 +1,111 @@ +import { ADDON_IDS } from '../../addons'; +import { + McpController, + TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + Tool, + ok, + type McpContext, +} from '../../nest-mcp'; +import { addonGate, type HasAddons } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { JourneyRecordingsService } from './journey-recordings.service'; +import { + idSchema, + journeyRecordingDateSchema, + journeyRecordingPreviewRequestSchema, + journeyRecordingSaveRequestSchema, + type JourneyRecordedDay, + type JourneyRecordingPreviewRequest, + type JourneyRecordingSaveRequest, +} from '@trek/shared'; + +const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY); +const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH); + +/** Taking a day over reads Dawarich into a journey, so both addons have to be on. */ +const bothAddonsOn = async (ctx: McpContext, self: HasAddons): Promise => + (await journeyAddonOn(ctx, self)) && (await dawarichAddonOn(ctx, self)); + +/** + * The recorded days a journey keeps (JRN-01) over MCP: the same service and + * the same journey access as `/api/journeys/:id/recordings`. The lines stay + * on the map; a tool answers with what a day covers, never its geometry. + */ +@McpController() +export class JourneyRecordingsMcp { + constructor( + private readonly recordings: JourneyRecordingsService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'list_journey_recorded_days', + description: + 'The days of route recorded in Dawarich that a journey keeps, one per local day: the zone the day was cut in, what it covers (distance, pieces, gaps without recording, modes, first and last time), whether the source was incomplete or the day still running when it was taken over, and whose recording it is. Kept days stay readable when Dawarich is unreachable.', + inputSchema: { journeyId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: journeyAddonOn, + access: { group: 'journey', mode: 'read' }, + }) + async listRecordedDays({ journeyId }: { journeyId: number }, ctx: McpContext) { + return ok({ days: (await this.recordings.list(ctx.userId, journeyId)).map(summary) }); + } + + @Tool({ + name: 'preview_journey_recorded_day', + description: + "Read one local day of the route recorded in the caller's own Dawarich and show what a journey would keep, without keeping anything: the window the day runs in, what it covers, whether the journey already keeps that day and if it is the same, how many other people would see it, and the contentHash save_journey_recorded_day needs. utcOffsetMinutes is the zone to cut the day in; useSourceOffset cuts it in Dawarich's own zone where its answer shows one. Needs edit access to the journey.", + inputSchema: { journeyId: idSchema, ...journeyRecordingPreviewRequestSchema.shape }, + // Reaches the caller's own Dawarich instance rather than TREK's database. + annotations: { ...TOOL_ANNOTATIONS_READONLY, openWorldHint: true }, + when: bothAddonsOn, + access: { group: 'journey', mode: 'read' }, + }) + async previewRecordedDay( + { journeyId, ...request }: { journeyId: number } & JourneyRecordingPreviewRequest, + ctx: McpContext, + ) { + const preview = await this.recordings.preview(ctx.userId, journeyId, request); + const { segments: _segments, stored, ...rest } = preview; + return ok({ ...rest, stored: stored ? summary(stored) : null }); + } + + @Tool({ + name: 'save_journey_recorded_day', + description: + "Keep one local day of the route recorded in the caller's Dawarich in a journey, after the traveller confirmed the preview. Send the utcOffsetMinutes and contentHash (as expectedHash) of preview_journey_recorded_day: when the recording changed since, nothing is kept and the answer says so. A day kept already exactly so is left as it is; a different version is replaced only with replace: true. Never changes an entry or a text of the journey. Everyone who can open the journey sees the route on its map.", + inputSchema: { journeyId: idSchema, date: journeyRecordingDateSchema, ...journeyRecordingSaveRequestSchema.shape }, + annotations: { ...TOOL_ANNOTATIONS_WRITE, openWorldHint: true }, + when: bothAddonsOn, + access: { group: 'journey', mode: 'write' }, + }) + async saveRecordedDay( + { journeyId, date, ...request }: { journeyId: number; date: string } & JourneyRecordingSaveRequest, + ctx: McpContext, + ) { + const { day, outcome } = await this.recordings.save(ctx.userId, journeyId, date, request); + return ok({ outcome, day: summary(day) }); + } + + @Tool({ + name: 'delete_journey_recorded_day', + description: + 'Drop the recorded route a journey keeps for one local day. Its entries and texts stay as they are, and Dawarich keeps its own recording. Needs edit access to the journey.', + inputSchema: { journeyId: idSchema, date: journeyRecordingDateSchema }, + annotations: TOOL_ANNOTATIONS_DELETE, + when: journeyAddonOn, + access: { group: 'journey', mode: 'write' }, + }) + async deleteRecordedDay({ journeyId, date }: { journeyId: number; date: string }, ctx: McpContext) { + await this.recordings.remove(ctx.userId, journeyId, date); + return ok({ success: true }); + } +} + +/** A kept day without its geometry, which is the whole payload and belongs on a map. */ +function summary(day: JourneyRecordedDay) { + const { segments: _segments, ...rest } = day; + return rest; +} diff --git a/server/src/nest/journey-recordings/journey-recordings.module.ts b/server/src/nest/journey-recordings/journey-recordings.module.ts new file mode 100644 index 000000000..222341338 --- /dev/null +++ b/server/src/nest/journey-recordings/journey-recordings.module.ts @@ -0,0 +1,20 @@ +import { AddonsModule } from '../addons/addons.module'; +import { DawarichModule } from '../integrations/dawarich.module'; +import { JourneyDomainModule } from '../journey/journey-domain.module'; +import { JourneyRecordingsController } from './journey-recordings.controller'; +import { JourneyRecordingsMcp } from './journey-recordings.mcp'; +import { JourneyRecordingsService } from './journey-recordings.service'; +import { Module } from '@nestjs/common'; + +/** + * A journey keeping the route recorded on its days (JRN-01): taking a day over + * from the caller's Dawarich connection, its routes and its MCP tools. The + * days themselves belong to the journey domain; this module only brings them + * in from Dawarich, so neither of the two has to import the other. + */ +@Module({ + imports: [JourneyDomainModule, DawarichModule, AddonsModule], + controllers: [JourneyRecordingsController], + providers: [JourneyRecordingsService, JourneyRecordingsMcp], +}) +export class JourneyRecordingsModule {} diff --git a/server/src/nest/journey-recordings/journey-recordings.service.ts b/server/src/nest/journey-recordings/journey-recordings.service.ts new file mode 100644 index 000000000..cca1b18b5 --- /dev/null +++ b/server/src/nest/journey-recordings/journey-recordings.service.ts @@ -0,0 +1,140 @@ +import { ADDON_IDS } from '../../addons'; +import { AddonsService } from '../addons/addons.service'; +import { DomainError, conflict, forbidden, notFound } from '../common/domain-error'; +import { DawarichTracksService } from '../integrations/dawarich-tracks.service'; +import { JourneyDomainService } from '../journey/journey-domain.service'; +import { JourneyRecordedDaysService } from '../journey/journey-recorded-days.service'; +import { Injectable } from '@nestjs/common'; +import { + recordingStats, + type JourneyRecordedDay, + type JourneyRecordingPreview, + type JourneyRecordingPreviewRequest, + type JourneyRecordingSaveRequest, + type JourneyRecordingSaveResult, +} from '@trek/shared'; + +/** + * Taking a day's recorded route over into a journey (JRN-01): a live preview + * of the day from the caller's own Dawarich connection, then, on their word, + * a copy the journey keeps. + * + * The preview writes nothing. The save reads the day again and keeps it only + * when it is still the one previewed (its hash), so a recording that grew in + * between is shown again before it is kept. A failed read throws before + * anything is written, so the version a journey kept stays as it was. The + * same day taken over twice keeps one version; a different one replaces it + * only when asked to, and never touches an entry or a text of the journey. + * + * Whoever may edit the journey may keep their own recording in it; everyone + * who can open the journey sees it on the map, which the preview says. + */ +@Injectable() +export class JourneyRecordingsService { + constructor( + private readonly journeys: JourneyDomainService, + private readonly recordedDays: JourneyRecordedDaysService, + private readonly tracks: DawarichTracksService, + private readonly addons: AddonsService, + ) {} + + /** The days a journey keeps, for anyone who can open it. Needs no Dawarich. */ + async list(userId: number, journeyId: number): Promise { + if (!(await this.journeys.canAccessJourney(journeyId, userId))) throw notFound('Journey not found'); + return await this.recordedDays.list(journeyId); + } + + async preview( + userId: number, + journeyId: number, + request: JourneyRecordingPreviewRequest, + ): Promise { + const journey = await this.editable(journeyId, userId); + await this.requireDawarich(); + const day = await this.tracks.fetchDay(userId, request.date, { + offsetMinutes: request.utcOffsetMinutes, + useSourceOffset: request.useSourceOffset ?? false, + }); + const contentHash = day.segments.length > 0 ? this.recordedDays.hashOf(day.segments, day.utcOffsetMinutes) : null; + const stored = await this.recordedDays.find(journeyId, request.date); + return { + journey: { id: journey.id, title: journey.title }, + date: request.date, + utcOffsetMinutes: day.utcOffsetMinutes, + offsetSource: day.offsetSource, + sourceOffsetMinutes: day.sourceOffsetMinutes, + window: day.window, + sourceKind: day.sourceKind, + segments: day.segments, + stats: recordingStats(day.segments), + truncated: day.truncated, + provisional: Date.parse(day.fetchedAt) < Date.parse(day.window.to), + fetchedAt: day.fetchedAt, + contentHash, + stored, + sameAsStored: stored !== null && stored.contentHash === contentHash, + audience: await this.recordedDays.audience(journeyId, userId), + }; + } + + async save( + userId: number, + journeyId: number, + date: string, + request: JourneyRecordingSaveRequest, + socketId?: string, + ): Promise { + await this.editable(journeyId, userId); + await this.requireDawarich(); + const day = await this.tracks.fetchDay(userId, date, { + offsetMinutes: request.utcOffsetMinutes, + useSourceOffset: false, + }); + if (day.segments.length === 0) { + throw new DomainError(422, 'Dawarich recorded nothing on that day', { details: { code: 'recording_empty' } }); + } + const contentHash = this.recordedDays.hashOf(day.segments, day.utcOffsetMinutes); + if (contentHash !== request.expectedHash) { + throw conflict('The recording changed since the preview', { code: 'recording_changed' }); + } + + const stored = await this.recordedDays.find(journeyId, date); + if (stored?.contentHash === contentHash) return { day: stored, outcome: 'unchanged' }; + if (stored && !request.replace) { + throw conflict('The journey already keeps another recording of that day', { code: 'recording_exists' }); + } + + const kept = await this.recordedDays.keep(journeyId, userId, { + date, + sourceKind: day.sourceKind, + utcOffsetMinutes: day.utcOffsetMinutes, + window: day.window, + segments: day.segments, + truncated: day.truncated, + fetchedAt: day.fetchedAt, + contentHash, + }); + await this.journeys.broadcastJourneyEvent(journeyId, 'journey:recording:updated', { date }, socketId); + return { day: kept, outcome: stored ? 'replaced' : 'created' }; + } + + /** Drops the day a journey keeps. Needs no Dawarich: a kept day stays removable once the connection is gone. */ + async remove(userId: number, journeyId: number, date: string, socketId?: string): Promise { + await this.editable(journeyId, userId); + if (!(await this.recordedDays.remove(journeyId, date))) throw notFound('No recording kept for that day'); + await this.journeys.broadcastJourneyEvent(journeyId, 'journey:recording:updated', { date }, socketId); + } + + private async editable(journeyId: number, userId: number) { + const journey = await this.journeys.canAccessJourney(journeyId, userId); + if (!journey) throw notFound('Journey not found'); + if (!(await this.journeys.canEdit(journeyId, userId))) throw forbidden('Not allowed'); + return journey; + } + + private async requireDawarich(): Promise { + if (!(await this.addons.isAddonEnabled(ADDON_IDS.DAWARICH))) { + throw notFound('Dawarich is not enabled', { code: 'addon_disabled' }); + } + } +} diff --git a/server/src/nest/journey/journey-domain.module.ts b/server/src/nest/journey/journey-domain.module.ts index 69954a3dd..7dc5cf392 100644 --- a/server/src/nest/journey/journey-domain.module.ts +++ b/server/src/nest/journey/journey-domain.module.ts @@ -2,6 +2,7 @@ import { JourneyContributors } from '../../db/entities/JourneyContributors.entit import { JourneyEntries } from '../../db/entities/JourneyEntries.entity'; import { JourneyEntryPhotos } from '../../db/entities/JourneyEntryPhotos.entity'; import { JourneyPhotos } from '../../db/entities/JourneyPhotos.entity'; +import { JourneyRecordedDays } from '../../db/entities/JourneyRecordedDays.entity'; import { JourneyShareTokens } from '../../db/entities/JourneyShareTokens.entity'; import { JourneyTrips } from '../../db/entities/JourneyTrips.entity'; import { Journeys } from '../../db/entities/Journeys.entity'; @@ -12,6 +13,7 @@ import { TrekPhotosModule } from '../photos/trek-photos.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { SettingsModule } from '../settings/settings.module'; import { JourneyDomainService } from './journey-domain.service'; +import { JourneyRecordedDaysService } from './journey-recorded-days.service'; import { JourneyShareService } from './journey-share.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -61,6 +63,9 @@ import { Module } from '@nestjs/common'; * for JS1-JS15, plus reuses the already-registered `JourneysRepository` for * JS8/JS12 — no other module needed touching since both were already in * this array or this module's own provider graph. + * `JourneyRecordedDays` (JRN-01) is the days of recorded route a journey + * keeps; `JourneyRecordedDaysService` reads and writes them here, so the map + * needs nothing of the integration they were taken over from. */ @Module({ imports: [ @@ -78,9 +83,10 @@ import { Module } from '@nestjs/common'; JourneyEntryPhotos, Places, JourneyShareTokens, + JourneyRecordedDays, ]), ], - providers: [JourneyDomainService, JourneyShareService], - exports: [JourneyDomainService, JourneyShareService], + providers: [JourneyDomainService, JourneyShareService, JourneyRecordedDaysService], + exports: [JourneyDomainService, JourneyShareService, JourneyRecordedDaysService], }) export class JourneyDomainModule {} diff --git a/server/src/nest/journey/journey-recorded-days.service.ts b/server/src/nest/journey/journey-recorded-days.service.ts new file mode 100644 index 000000000..52d33faba --- /dev/null +++ b/server/src/nest/journey/journey-recorded-days.service.ts @@ -0,0 +1,118 @@ +import { JourneyRecordedDays } from '../../db/entities/JourneyRecordedDays.entity'; +import { Journeys } from '../../db/entities/Journeys.entity'; +import { JOURNEY_RECORDED_DAY_SEGMENTS } from '../../db/json-columns'; +import type { + JourneyRecordedDayRow, + JourneyRecordedDaysRepository, +} from '../../db/repositories/JourneyRecordedDays.repository'; +import type { JourneysRepository } from '../../db/repositories/Journeys.repository'; +import { parseDbTimestamp } from '../../db/types'; +import { decodeJson } from '../../utils/json-column'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { recordingStats, type DawarichTrackSegment, type JourneyRecordedDay } from '@trek/shared'; + +import crypto from 'crypto'; + +/** A day's recording as it is handed over to be kept. */ +interface RecordingToKeep { + date: string; + sourceKind: 'tracks' | 'points'; + utcOffsetMinutes: number; + window: { from: string; to: string }; + segments: DawarichTrackSegment[]; + truncated: boolean; + fetchedAt: string; + contentHash: string; +} + +const SOURCE = 'dawarich'; + +/** + * The recorded days a journey keeps (JRN-01). The journey owns them, so its + * map can draw them without the integration they came from; taking a day + * over from Dawarich is `journey-recordings`' part. + */ +@Injectable() +export class JourneyRecordedDaysService { + constructor( + @InjectRepository(JourneyRecordedDays) private readonly days: JourneyRecordedDaysRepository, + @InjectRepository(Journeys) private readonly journeys: JourneysRepository, + ) {} + + /** + * What a recording says, as one value: equal hashes are the same line cut in + * the same zone, so taking it over again keeps nothing new. + */ + hashOf(segments: DawarichTrackSegment[], utcOffsetMinutes: number): string { + return crypto + .createHash('sha256') + .update(`${utcOffsetMinutes}|${JSON.stringify(segments)}`) + .digest('hex'); + } + + async list(journeyId: number): Promise { + return (await this.days.listForJourney(journeyId)).map(toRecordedDay); + } + + async find(journeyId: number, date: string): Promise { + const row = await this.days.findDay(journeyId, date, SOURCE); + return row ? toRecordedDay(row) : null; + } + + /** Keeps a day's recording, replacing what the journey kept for that day, and answers it as kept. */ + async keep(journeyId: number, userId: number, recording: RecordingToKeep): Promise { + const stats = recordingStats(recording.segments); + await this.days.upsertDay({ + journey_id: journeyId, + local_date: recording.date, + source: SOURCE, + source_kind: recording.sourceKind, + utc_offset_minutes: recording.utcOffsetMinutes, + window_from: recording.window.from, + window_to: recording.window.to, + segments: JSON.stringify(recording.segments), + point_count: stats.pointCount, + distance_m: stats.distanceMeters, + content_hash: recording.contentHash, + truncated: recording.truncated ? 1 : 0, + fetched_at: recording.fetchedAt, + imported_by: userId, + }); + const kept = await this.find(journeyId, recording.date); + if (!kept) throw new Error(`Recorded day ${recording.date} of journey ${journeyId} vanished after its write`); + return kept; + } + + async remove(journeyId: number, date: string): Promise { + return await this.days.deleteDay(journeyId, date, SOURCE); + } + + /** How many people besides `userId` can open the journey, and so would see its map. */ + async audience(journeyId: number, userId: number): Promise { + return (await this.journeys.listRecipientUserIds(journeyId)).filter((id) => id !== userId).length; + } +} + +function toRecordedDay(row: JourneyRecordedDayRow): JourneyRecordedDay { + const segments = decodeJson(JOURNEY_RECORDED_DAY_SEGMENTS, row.segments, `recorded day ${row.id}`); + return { + id: row.id, + date: row.local_date, + source: SOURCE, + sourceKind: row.source_kind === 'tracks' ? 'tracks' : 'points', + utcOffsetMinutes: row.utc_offset_minutes, + window: { from: row.window_from, to: row.window_to }, + segments, + stats: recordingStats(segments), + truncated: row.truncated === 1, + provisional: Date.parse(row.fetched_at) < Date.parse(row.window_to), + fetchedAt: row.fetched_at, + contentHash: row.content_hash, + importedBy: + row.imported_by !== null && row.imported_by_username !== null + ? { id: row.imported_by, username: row.imported_by_username } + : null, + savedAt: (parseDbTimestamp(row.updated_at) ?? new Date(0)).toISOString(), + }; +} diff --git a/server/tests/e2e/journey-recordings.e2e.test.ts b/server/tests/e2e/journey-recordings.e2e.test.ts new file mode 100644 index 000000000..fd172e33d --- /dev/null +++ b/server/tests/e2e/journey-recordings.e2e.test.ts @@ -0,0 +1,295 @@ +/** + * Recorded days a journey keeps (JRN-01), through the real AddonGuard and + * JwtAuthGuard against a migrated and seeded temp SQLite db, every answer + * checked against its response contract. The Dawarich client is the one + * thing replaced: a test never opens a socket to a location archive. The + * broadcasts go to a FakeRealtimeService. + * + * Fixtures: user 1 owns journey 7 and has a Dawarich connection, user 2 is an + * editor of it with no connection, user 3 a viewer, user 4 has no access. + * + * The walk: preview, keep, read the day back once the connection is gone, + * take the same day over again without a second copy, replace it on purpose, + * keep the version when Dawarich fails, and drop it. + */ +import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity'; +import { JourneyRecordedDays } from '../../src/db/entities/JourneyRecordedDays.entity'; +import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { DawarichClient, DawarichError } from '../../src/nest/integrations/dawarich.client'; +import { JourneyRecordingsModule } from '../../src/nest/journey-recordings/journey-recordings.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys'; +import { countRows, deleteRows, insertRow } from '../helpers/factories/rows'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { makeUser } from '../helpers/factories/users'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const db = createSnapshotTestDb(); +const realtime = new FakeRealtimeService(); +const client = { + probe: vi.fn(), + listVisits: vi.fn(), + listTracks: vi.fn(), + listPoints: vi.fn(), + listVisitedCities: vi.fn(), + findVisitsNear: vi.fn(), +}; + +let orm: TestOrm; +let journeyId: number; + +const OWNER = 1; +const EDITOR = 2; +const VIEWER = 3; +const OUTSIDER = 4; +const DAY = '2026-05-14'; + +/** A walk across Venice on the 14th, in the zone Dawarich records in. */ +function walk(endLng = 12.3218) { + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [12.3388, 45.434], + [endLng, 45.4364], + ], + }, + properties: { + start_at: `${DAY}T09:00:00+02:00`, + end_at: `${DAY}T09:40:00+02:00`, + dominant_mode: 'walking', + distance: 1500, + }, + }; +} + +describe('Journey recordings e2e (real guards + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + RealtimeModule, + JourneyRecordingsModule, + ], + }) + .overrideProvider(RealtimeService) + .useValue(realtime) + .overrideProvider(DawarichClient) + .useValue(client) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + const as = (userId: number) => ({ Cookie: sessionCookie(userId) }); + const base = () => `/api/journeys/${journeyId}/recordings`; + + const preview = (userId = OWNER) => + request(server) + .post(`${base()}/preview`) + .set(as(userId)) + .send({ date: DAY, utcOffsetMinutes: 0, useSourceOffset: true }); + + beforeAll(async () => { + orm = await createTestOrm(db); + // Pinned ids: the session cookies sign for exactly these users. + const user = (id: number, username: string) => + makeUser(orm, { id, username, email: `${username}@example.test`, role: 'user', password_version: 0 }); + await user(OWNER, 'marta'); + await user(EDITOR, 'jonas'); + await user(VIEWER, 'viewer'); + await user(OUTSIDER, 'outsider'); + const journey = await makeJourney(orm, OWNER, { title: 'Venice' }); + journeyId = journey.id; + await addJourneyContributor(orm, journeyId, EDITOR, 'editor'); + await addJourneyContributor(orm, journeyId, VIEWER, 'viewer'); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await setAddonEnabled(orm, 'journey', true); + await setAddonEnabled(orm, 'dawarich', true); + await deleteRows(orm, JourneyRecordedDays); + await deleteRows(orm, DawarichConnections); + await insertRow(orm, DawarichConnections, { + user: OWNER, + url: 'https://dawarich.example', + api_key: encrypt_api_key('secret-key'), + }); + for (const fn of Object.values(client)) fn.mockReset(); + client.listTracks.mockResolvedValue({ features: [walk()], truncated: false }); + client.listPoints.mockResolvedValue({ points: [], truncated: false }); + realtime.broadcastToUserMock.mockClear(); + }); + + afterAll(async () => { + await app.close(); + await orm.close(); + }); + + it('answers 404 with the Journey addon off before asking who the caller is, and 401 without a session', async () => { + await setAddonEnabled(orm, 'journey', false); + expect((await request(server).get(base())).status).toBe(404); + await setAddonEnabled(orm, 'journey', true); + expect((await request(server).get(base())).status).toBe(401); + }); + + it('previews the day for the journey without keeping it, then keeps it on the word of the traveller', async () => { + const shown = await preview(); + expect(shown.status).toBe(200); + expect(shown.body).toMatchObject({ + journey: { id: journeyId, title: 'Venice' }, + date: DAY, + utcOffsetMinutes: 120, + offsetSource: 'source', + window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' }, + sourceKind: 'tracks', + stats: { distanceMeters: 1500, segmentCount: 1, gapCount: 0, modes: ['walking'] }, + stored: null, + audience: 2, + }); + expect(await countRows(orm, JourneyRecordedDays)).toBe(0); + + const kept = await request(server) + .put(`${base()}/${DAY}`) + .set(as(OWNER)) + .set('X-Socket-Id', '11') + .send({ utcOffsetMinutes: 120, expectedHash: shown.body.contentHash }); + expect(kept.status).toBe(200); + expect(kept.body).toMatchObject({ outcome: 'created', day: { date: DAY, importedBy: { username: 'marta' } } }); + expect(realtime.broadcastToUserMock).toHaveBeenCalledWith( + VIEWER, + { type: 'journey:recording:updated', journeyId, date: DAY }, + '11', + ); + }); + + it('keeps the day readable for every member once the connection is gone, and never copies it twice', async () => { + const shown = await preview(); + const body = { utcOffsetMinutes: 120, expectedHash: shown.body.contentHash }; + await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body); + + const again = await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body); + expect(again.status).toBe(200); + expect(again.body.outcome).toBe('unchanged'); + expect(await countRows(orm, JourneyRecordedDays)).toBe(1); + + await deleteRows(orm, DawarichConnections); + await setAddonEnabled(orm, 'dawarich', false); + client.listTracks.mockReset(); + const listed = await request(server).get(base()).set(as(VIEWER)); + expect(listed.status).toBe(200); + expect(listed.body.days).toHaveLength(1); + expect(listed.body.days[0]).toMatchObject({ date: DAY, segments: [{ mode: 'walking' }] }); + expect(client.listTracks).not.toHaveBeenCalled(); + expect((await request(server).get(base()).set(as(OUTSIDER))).status).toBe(404); + }); + + it('replaces a kept day only when asked, and a failing Dawarich leaves the kept version as it was', async () => { + const first = await preview(); + await request(server) + .put(`${base()}/${DAY}`) + .set(as(OWNER)) + .send({ utcOffsetMinutes: 120, expectedHash: first.body.contentHash }); + + client.listTracks.mockResolvedValue({ features: [walk(12.3), walk(12.31)], truncated: false }); + const grown = await preview(); + expect(grown.body).toMatchObject({ sameAsStored: false, stored: { stats: { segmentCount: 1 } } }); + const body = { utcOffsetMinutes: 120, expectedHash: grown.body.contentHash }; + + const refused = await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body); + expect(refused.status).toBe(409); + expect(refused.body).toEqual({ + error: 'The journey already keeps another recording of that day', + code: 'recording_exists', + }); + + client.listTracks.mockRejectedValue(new DawarichError('server_error', 'Dawarich answered HTTP 500', 500)); + client.listPoints.mockRejectedValue(new DawarichError('server_error', 'Dawarich answered HTTP 500', 500)); + const failed = await request(server) + .put(`${base()}/${DAY}`) + .set(as(OWNER)) + .send({ ...body, replace: true }); + expect(failed.status).toBe(502); + expect(failed.body).toMatchObject({ code: 'server_error' }); + const unchanged = await request(server).get(base()).set(as(OWNER)); + expect(unchanged.body.days[0].contentHash).toBe(first.body.contentHash); + + client.listTracks.mockResolvedValue({ features: [walk(12.3), walk(12.31)], truncated: false }); + const replaced = await request(server) + .put(`${base()}/${DAY}`) + .set(as(OWNER)) + .send({ ...body, replace: true }); + expect(replaced.status).toBe(200); + expect(replaced.body.outcome).toBe('replaced'); + expect(await countRows(orm, JourneyRecordedDays)).toBe(1); + }); + + it('an editor without a connection is told so, a viewer may not preview, and a bad body or day is a 400', async () => { + const unconnected = await preview(EDITOR); + expect(unconnected.status).toBe(409); + expect(unconnected.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); + + expect((await preview(VIEWER)).status).toBe(403); + expect((await preview(OUTSIDER)).status).toBe(404); + + const badBody = await request(server) + .post(`${base()}/preview`) + .set(as(OWNER)) + .send({ date: DAY, utcOffsetMinutes: 2000 }); + expect(badBody.status).toBe(400); + const badDay = await request(server) + .put(`${base()}/2026-13-01`) + .set(as(OWNER)) + .send({ utcOffsetMinutes: 0, expectedHash: 'a'.repeat(64) }); + expect(badDay.status).toBe(400); + expect(badDay.body).toEqual({ error: 'Invalid date' }); + }); + + it('drops a kept day for an editor, with Dawarich off, and a second drop is a 404', async () => { + const shown = await preview(); + await request(server) + .put(`${base()}/${DAY}`) + .set(as(OWNER)) + .send({ utcOffsetMinutes: 120, expectedHash: shown.body.contentHash }); + await setAddonEnabled(orm, 'dawarich', false); + + expect((await request(server).delete(`${base()}/${DAY}`).set(as(VIEWER))).status).toBe(403); + const dropped = await request(server).delete(`${base()}/${DAY}`).set(as(EDITOR)); + expect(dropped.status).toBe(200); + expect(dropped.body).toEqual({ success: true }); + expect(await countRows(orm, JourneyRecordedDays)).toBe(0); + expect((await request(server).delete(`${base()}/${DAY}`).set(as(EDITOR))).status).toBe(404); + }); +}); diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index 7a12cdf9d..59717ede3 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -46,14 +46,15 @@ describe('ALL_ENTITIES', () => { * + tour_activity_exclusions, the tours a user left out of their own activity sums * + collection_tours, the whole tours a collection keeps * + tour_packing_links, the packing list or category a tour points at - * + tour_source_hints, what the wanderer watch found about a snapshot's source) + * + tour_source_hints, what the wanderer watch found about a snapshot's source + * + journey_recorded_days, the recorded route a journey keeps per day) * is registered, and no two share a class name (a silent duplicate would * shadow one of them in `orm.getMetadata()`, exactly the kind of drift * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 149 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(149); + it('ENT-003: exactly 150 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(150); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index 83fb1f97b..07abe663a 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -43,7 +43,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name // (a kept tour is only ever written whole, from the tour it was saved from). // + tour_source_hints: user_id (a hint is only ever written for the user whose snapshot it is about). -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 519; +// + journey_recorded_days: id, journey_id, local_date, source_kind, utc_offset_minutes, window_from, +// window_to, segments, point_count, content_hash, fetched_at (a kept day is only ever written whole). +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 530; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => { diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index b5c81086b..b2c20da83 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -82,6 +82,7 @@ const UNNUMBERED = [ 'Migration20200101046130_snowshoeing_and_winter_hiking_are_ski_disciplines', 'Migration20200101046140_tours_are_on_for_every_install', 'Migration20200101046150_a_ski_tour_keeps_its_piste_router_options', + 'Migration20200101046160_a_journey_keeps_its_recorded_days', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/JourneyRecordedDays.repository.test.ts b/server/tests/unit/db/repositories/JourneyRecordedDays.repository.test.ts new file mode 100644 index 000000000..c6c5be5a1 --- /dev/null +++ b/server/tests/unit/db/repositories/JourneyRecordedDays.repository.test.ts @@ -0,0 +1,155 @@ +/** + * `JourneyRecordedDaysRepository` (JRN-01): a day a journey keeps written + * whole, read back with the name of whose recording it is, one version per + * journey, day and source with a second write replacing the first in place, + * removed on its own, gone with its journey and kept past its importer. + */ +import { JourneyRecordedDays } from '../../../../src/db/entities/JourneyRecordedDays.entity'; +import { Journeys } from '../../../../src/db/entities/Journeys.entity'; +import { Users } from '../../../../src/db/entities/Users.entity'; +import type { + JourneyRecordedDaysRepository, + JourneyRecordedDayWrite, +} from '../../../../src/db/repositories/JourneyRecordedDays.repository'; +import { dbNow } from '../../../../src/db/types'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { makeJourney } from '../../../helpers/factories/journeys'; +import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows'; +import { makeUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let days: JourneyRecordedDaysRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + days = t.repo(JourneyRecordedDays); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +async function journey() { + const { user } = await makeUser(t, { username: 'marta' }); + const j = await makeJourney(t, user.id); + return { user, journey: j }; +} + +function day(journeyId: number, importedBy: number | null, overrides: Partial = {}) { + return { + journey_id: journeyId, + local_date: '2026-05-14', + source: 'dawarich', + source_kind: 'tracks', + utc_offset_minutes: 120, + window_from: '2026-05-13T22:00:00.000Z', + window_to: '2026-05-14T22:00:00.000Z', + segments: '[{"points":[[45.43,12.33],[45.44,12.34]],"mode":"walking"}]', + point_count: 2, + distance_m: 1300, + content_hash: 'a'.repeat(64), + truncated: 0, + fetched_at: '2026-05-15T08:00:00.000Z', + imported_by: importedBy, + ...overrides, + } satisfies JourneyRecordedDayWrite; +} + +describe('JourneyRecordedDaysRepository', () => { + it('JRDAYS-001: keeps a day whole and reads it back with the name of whose recording it is', async () => { + const { user, journey: j } = await journey(); + const id = await days.upsertDay(day(j.id, user.id)); + + const row = await days.findDay(j.id, '2026-05-14', 'dawarich'); + expect(row).toMatchObject({ + id, + journey_id: j.id, + local_date: '2026-05-14', + source_kind: 'tracks', + utc_offset_minutes: 120, + window_from: '2026-05-13T22:00:00.000Z', + point_count: 2, + distance_m: 1300, + truncated: 0, + imported_by: user.id, + imported_by_username: 'marta', + }); + expect(row?.created_at).toEqual(expect.any(String)); + expect(await days.findDay(j.id, '2026-05-15', 'dawarich')).toBeUndefined(); + }); + + it('JRDAYS-002: a second write of the same day replaces the first in place, keeping its id', async () => { + const { user, journey: j } = await journey(); + const first = await days.upsertDay(day(j.id, user.id)); + const second = await days.upsertDay( + day(j.id, user.id, { content_hash: 'b'.repeat(64), point_count: 40, source_kind: 'points', distance_m: null }), + ); + + expect(second).toBe(first); + expect(await countRows(t, JourneyRecordedDays, {})).toBe(1); + expect(await days.findDay(j.id, '2026-05-14', 'dawarich')).toMatchObject({ + content_hash: 'b'.repeat(64), + point_count: 40, + source_kind: 'points', + distance_m: null, + }); + }); + + it('JRDAYS-003: lists the days of one journey earliest first, and only its own', async () => { + const { user, journey: j } = await journey(); + const other = await makeJourney(t, user.id); + await days.upsertDay(day(j.id, user.id, { local_date: '2026-05-16' })); + await days.upsertDay(day(j.id, user.id, { local_date: '2026-05-14' })); + await days.upsertDay(day(other.id, user.id, { local_date: '2026-05-15' })); + + expect((await days.listForJourney(j.id)).map((row) => row.local_date)).toEqual(['2026-05-14', '2026-05-16']); + expect(await days.listForJourney(9999)).toEqual([]); + }); + + it('JRDAYS-004: removes one day and says whether there was one', async () => { + const { user, journey: j } = await journey(); + await days.upsertDay(day(j.id, user.id)); + await days.upsertDay(day(j.id, user.id, { local_date: '2026-05-15' })); + + expect(await days.deleteDay(j.id, '2026-05-14', 'dawarich')).toBe(true); + expect(await days.deleteDay(j.id, '2026-05-14', 'dawarich')).toBe(false); + expect((await days.listForJourney(j.id)).map((row) => row.local_date)).toEqual(['2026-05-15']); + }); + + it('JRDAYS-005: a day goes with its journey and outlives the account that took it over', async () => { + const { journey: j } = await journey(); + const { user: editor } = await makeUser(t); + await days.upsertDay(day(j.id, editor.id)); + + await deleteRows(t, Users, { id: editor.id }); + expect(await days.findDay(j.id, '2026-05-14', 'dawarich')).toMatchObject({ + imported_by: null, + imported_by_username: null, + }); + + await deleteRows(t, Journeys, { id: j.id }); + expect(await countRows(t, JourneyRecordedDays, {})).toBe(0); + }); + + it('JRDAYS-006: a replacing write stamps the time it was kept again', async () => { + const { user, journey: j } = await journey(); + await days.upsertDay(day(j.id, user.id)); + const longAgo = dbNow(new Date('2020-01-01T00:00:00Z')); + await updateRows(t, JourneyRecordedDays, { journey: j.id }, { created_at: longAgo, updated_at: longAgo }); + + await days.upsertDay(day(j.id, user.id, { content_hash: 'c'.repeat(64) })); + + const row = await days.findDay(j.id, '2026-05-14', 'dawarich'); + expect(row?.created_at).toBe(longAgo); + expect(row?.updated_at).not.toBe(longAgo); + }); +}); diff --git a/server/tests/unit/nest/dawarich-tracks.service.test.ts b/server/tests/unit/nest/dawarich-tracks.service.test.ts index 97b741913..e6f111393 100644 --- a/server/tests/unit/nest/dawarich-tracks.service.test.ts +++ b/server/tests/unit/nest/dawarich-tracks.service.test.ts @@ -1,6 +1,6 @@ /** * DawarichTracksService unit tests, DAWARICH-TRACKS-001 through - * DAWARICH-TRACKS-053. The service is built with `new` and every collaborator + * DAWARICH-TRACKS-061. The service is built with `new` and every collaborator * is a plain mock: no Nest container, no database, no socket. The helpers it * delegates the actual geometry to (`bucketPointsByDay`, `bucketTracksByDay`) * are the real ones, because what this service decides is *which* of them runs @@ -43,6 +43,7 @@ * can name rather than merely "some string". */ import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; +import { DomainError } from '../../../src/nest/common/domain-error'; import { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service'; import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; import type { @@ -713,3 +714,154 @@ describe('DawarichTracksService source selection', () => { expect(track.pointCount).toBe(5); }); }); + +// --------------------------------------------------------------------------- +// fetchDay: one local day for a journey to keep (JRN-01) +// --------------------------------------------------------------------------- + +describe('DawarichTracksService.fetchDay', () => { + const CHOSEN_UTC = { offsetMinutes: 0, useSourceOffset: false }; + + it('DAWARICH-TRACKS-054: without a connection it refuses with not_connected and asks nobody', async () => { + const h = harness({ creds: null }); + + const refusal = await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC).catch((err: unknown) => err); + + expect(refusal).toBeInstanceOf(DomainError); + expect((refusal as DomainError).getStatus()).toBe(409); + expect((refusal as DomainError).toBody()).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); + expect(h.listTracks).not.toHaveBeenCalled(); + expect(h.listPoints).not.toHaveBeenCalled(); + }); + + it('DAWARICH-TRACKS-055: asks one window wide enough for every zone and cuts the day in the zone chosen', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockResolvedValue({ + features: [feature({ start_at: '2026-05-01T10:00:00+02:00', end_at: '2026-05-01T12:00:00+02:00' })], + truncated: false, + }); + + const day = await h.service.fetchDay(USER, '2026-05-01', { offsetMinutes: 120, useSourceOffset: false }); + + const [, from, to] = h.listTracks.mock.calls[0] as [DawarichCreds, Date, Date]; + expect(from.toISOString()).toBe('2026-04-30T10:00:00.000Z'); + expect(to.toISOString()).toBe('2026-05-02T12:00:00.000Z'); + expect(day).toMatchObject({ + sourceKind: 'tracks', + utcOffsetMinutes: 120, + offsetSource: 'chosen', + sourceOffsetMinutes: 120, + window: { from: '2026-04-30T22:00:00.000Z', to: '2026-05-01T22:00:00.000Z' }, + fetchedAt: NOW_ISO, + truncated: false, + }); + expect(day.segments).toHaveLength(1); + expect(day.segments[0]).toMatchObject({ mode: 'car', distanceMeters: 41_000 }); + }); + + it('DAWARICH-TRACKS-056: asked to, it cuts the day in the zone Dawarich records in, which can move a track onto it', async () => { + const h = harness({ capabilities: CAPABILITIES }); + // 01:00 in Berlin is 23:00 the evening before in UTC. + h.listTracks.mockResolvedValue({ + features: [feature({ start_at: '2026-05-01T01:00:00+02:00', end_at: '2026-05-01T01:40:00+02:00' })], + truncated: false, + }); + + const own = await h.service.fetchDay(USER, '2026-05-01', { offsetMinutes: 0, useSourceOffset: true }); + expect(own).toMatchObject({ sourceKind: 'tracks', utcOffsetMinutes: 120, offsetSource: 'source' }); + expect(own.segments).toHaveLength(1); + + // Cut in UTC instead, the track belongs to the day before, and the points + // fallback finds nothing on this one either. + const utc = await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + expect(utc).toMatchObject({ sourceKind: 'points', utcOffsetMinutes: 0, offsetSource: 'chosen', segments: [] }); + }); + + it('DAWARICH-TRACKS-057: points keep the gaps of the recording: a silence that ends elsewhere is two pieces, not a line', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [ + point(52.0, 13.0, '2026-05-01T10:00:00Z'), + point(52.002, 13.002, '2026-05-01T10:05:00Z'), + // Three hours of nothing, then six kilometres away. + point(52.05, 13.05, '2026-05-01T13:05:00Z'), + point(52.052, 13.052, '2026-05-01T13:10:00Z'), + ], + truncated: false, + }); + + const day = await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + + expect(day.sourceKind).toBe('points'); + expect(day.sourceOffsetMinutes).toBeNull(); + expect(day.segments.map((segment) => segment.points)).toEqual([ + [ + [52.0, 13.0], + [52.002, 13.002], + ], + [ + [52.05, 13.05], + [52.052, 13.052], + ], + ]); + }); + + it('DAWARICH-TRACKS-058: a silence that ends where it began is no gap, and nothing of the day before is joined on', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [ + point(51.0, 12.0, '2026-04-30T22:00:00Z'), + point(52.0, 13.0, '2026-05-01T10:00:00Z'), + // An hour at a table in a cafe, then on from the same spot. + point(52.0001, 13.0001, '2026-05-01T11:00:00Z'), + point(52.01, 13.01, '2026-05-01T11:10:00Z'), + ], + truncated: false, + }); + + const day = await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + + expect(day.segments).toHaveLength(1); + expect(day.segments[0]?.points[0]).toEqual([52.0, 13.0]); + }); + + it('DAWARICH-TRACKS-059: a failing instance refuses with its reason, a refused key as 400 and anything else as 502', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockRejectedValueOnce(new DawarichError('server_error', 'Dawarich answered HTTP 500', 500)); + const failed = (await h.service + .fetchDay(USER, '2026-05-01', CHOSEN_UTC) + .catch((err: unknown) => err)) as DomainError; + expect(failed.getStatus()).toBe(502); + expect(failed.toBody()).toEqual({ error: 'Dawarich answered HTTP 500', code: 'server_error' }); + + h.listPoints.mockRejectedValueOnce(new DawarichError('unauthorized', 'Dawarich rejected the API key', 401)); + const refused = (await h.service + .fetchDay(USER, '2026-05-01', CHOSEN_UTC) + .catch((err: unknown) => err)) as DomainError; + expect(refused.getStatus()).toBe(400); + expect(refused.toBody()).toMatchObject({ code: 'unauthorized' }); + }); + + it('DAWARICH-TRACKS-060: a tracks failure falls back to points instead of failing the day', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockRejectedValue(new DawarichError('not_found', 'no tracks endpoint', 404)); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')], + truncated: true, + }); + + const day = await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + + expect(day).toMatchObject({ sourceKind: 'points', truncated: true }); + expect(day.segments).toHaveLength(1); + }); + + it('DAWARICH-TRACKS-061: reads past the cache, so what a journey keeps is what the instance says now', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + + await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + await h.service.fetchDay(USER, '2026-05-01', CHOSEN_UTC); + + expect(h.listPoints).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts index cb964a07b..cca9d40f6 100644 --- a/server/tests/unit/nest/dawarich.helpers.test.ts +++ b/server/tests/unit/nest/dawarich.helpers.test.ts @@ -15,6 +15,7 @@ import type { DawarichVisitRaw, } from '../../../src/nest/integrations/dawarich.client'; import { + RECORDING_GAP, bucketPointsByDay, bucketTracksByDay, capPoints, @@ -27,6 +28,7 @@ import { normalizeVisit, offsetMinutesOf, simplify, + splitAtGaps, splitWindow, syncWindow, toNumber, @@ -800,6 +802,41 @@ describe('bucketPointsByDay', () => { expect(line.at(-1)).toEqual([0, 2]); }); + it('DAWARICH-BUCKET-011: with gaps kept, a day becomes one piece per run and is never joined to the day before', () => { + const days = bucketPointsByDay( + [ + point('2024-06-14T23:00:00Z', 9, 9), + point('2024-06-15T08:00:00Z', 10, 10), + point('2024-06-15T08:10:00Z', 10.001, 10.001), + point('2024-06-15T12:00:00Z', 11, 11), + point('2024-06-15T12:10:00Z', 11.001, 11.001), + ], + 0, + { gaps: RECORDING_GAP }, + ); + // The lone point of the 14th is no line, so that day is not emitted. + expect(days.map((d) => d.date)).toEqual(['2024-06-15']); + expect(days[0]?.segments.map((s) => s.points[0])).toEqual([ + [10, 10], + [11, 11], + ]); + expect(days[0]?.segments[1]?.startedAt).toBe('2024-06-15T12:00:00.000Z'); + }); + + it('DAWARICH-BUCKET-012: with gaps kept, a run that is one fix is left out rather than drawn as a dot', () => { + const days = bucketPointsByDay( + [ + point('2024-06-15T08:00:00Z', 10, 10), + point('2024-06-15T12:00:00Z', 11, 11), + point('2024-06-15T12:10:00Z', 11.001, 11), + ], + 0, + { gaps: RECORDING_GAP }, + ); + expect(days[0]?.segments).toHaveLength(1); + expect(days[0]?.segments[0]?.points[0]).toEqual([11, 11]); + }); + it('DAWARICH-BUCKET-010: a recorded day carries no mode and no measured distance', () => { const days = bucketPointsByDay([point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)], 0); expect(days[0]!.segments[0]!.mode).toBeNull(); @@ -1114,6 +1151,40 @@ describe('bucketTracksByDay', () => { it('DAWARICH-TRACK-013: an empty feature list is an empty result', () => { expect(bucketTracksByDay([])).toEqual([]); }); + + it('DAWARICH-TRACK-016: a zone given cuts at its own midnight, not at the one the times carry', () => { + const late = track('2024-06-15T23:30:00+02:00', '2024-06-15T23:50:00+02:00', [ + [10, 10], + [11, 11], + ]); + expect(bucketTracksByDay([late]).map((d) => d.date)).toEqual(['2024-06-15']); + // 21:30 UTC is the next day in Tokyo. + expect(bucketTracksByDay([late], { offsetMinutes: 540 }).map((d) => d.date)).toEqual(['2024-06-16']); + }); +}); + +// ── splitAtGaps ────────────────────────────────────────────────────────────── + +describe('splitAtGaps', () => { + const at = (minute: number, lat: number, lng: number) => ({ lat, lng, ts: minute * 60 }); + + it('DAWARICH-GAP-001: a silence that ends elsewhere starts a new run', () => { + const runs = splitAtGaps( + [at(0, 52, 13), at(5, 52.001, 13), at(200, 52.05, 13.05), at(205, 52.051, 13.05)], + RECORDING_GAP, + ); + expect(runs.map((run) => run.length)).toEqual([2, 2]); + }); + + it('DAWARICH-GAP-002: a long silence in the same spot and a quick long hop are both one movement', () => { + expect(splitAtGaps([at(0, 52, 13), at(120, 52.0001, 13.0001)], RECORDING_GAP)).toHaveLength(1); + // A train: kilometres between two fixes, minutes apart. + expect(splitAtGaps([at(0, 52, 13), at(5, 52.1, 13.1)], RECORDING_GAP)).toHaveLength(1); + }); + + it('DAWARICH-GAP-003: no points, no runs', () => { + expect(splitAtGaps([], RECORDING_GAP)).toEqual([]); + }); }); // ── countPoints ────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/journey-recordings.controller.test.ts b/server/tests/unit/nest/journey-recordings.controller.test.ts new file mode 100644 index 000000000..96b00d7a6 --- /dev/null +++ b/server/tests/unit/nest/journey-recordings.controller.test.ts @@ -0,0 +1,93 @@ +/** + * JourneyRecordingsController (JRN-01): the routes of the recorded days a + * journey keeps, their guards, the date check it makes before the service is + * asked, and what it hands the service and answers. + */ +import 'reflect-metadata'; + +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard'; +import { JourneyRecordingsController } from '../../../src/nest/journey-recordings/journey-recordings.controller'; +import type { JourneyRecordingsService } from '../../../src/nest/journey-recordings/journey-recordings.service'; +import type { User } from '../../../src/types'; +import { HttpException, RequestMethod } from '@nestjs/common'; +import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; + +import { describe, expect, it, vi } from 'vitest'; + +const user = { id: 4 } as User; +const HASH = 'f'.repeat(64); + +function controllerWith() { + const service = { + list: vi.fn(async () => []), + preview: vi.fn(async () => ({ date: '2026-05-14' })), + save: vi.fn(async () => ({ outcome: 'created' })), + remove: vi.fn(async () => undefined), + }; + return { + service, + controller: new JourneyRecordingsController(service as unknown as JourneyRecordingsService), + }; +} + +describe('JourneyRecordingsController', () => { + it('JRNREC-CTL-001: sits behind the Journey addon before auth, with the preview and the removal answering 200', () => { + expect(Reflect.getMetadata(PATH_METADATA, JourneyRecordingsController)).toBe('api/journeys/:id/recordings'); + expect(Reflect.getMetadata(GUARDS_METADATA, JourneyRecordingsController)).toEqual([AddonGuard, JwtAuthGuard]); + expect(Reflect.getMetadata(REQUIRE_ADDON, JourneyRecordingsController)).toEqual({ + addonId: 'journey', + label: 'Journey', + }); + const routes = [ + ['list', RequestMethod.GET, '/', undefined], + ['preview', RequestMethod.POST, 'preview', 200], + ['save', RequestMethod.PUT, ':date', undefined], + ['remove', RequestMethod.DELETE, ':date', 200], + ] as const; + for (const [handler, method, path, code] of routes) { + const fn = JourneyRecordingsController.prototype[handler]; + expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method); + expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path); + expect(Reflect.getMetadata(HTTP_CODE_METADATA, fn)).toBe(code); + } + }); + + it('JRNREC-CTL-002: hands the caller, the journey, the day, the body and the socket id to the service', async () => { + const { service, controller } = controllerWith(); + + expect(await controller.list(user, '9')).toEqual({ days: [] }); + expect(service.list).toHaveBeenCalledExactlyOnceWith(4, 9); + + const preview = { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true }; + expect(await controller.preview(user, '9', preview)).toEqual({ date: '2026-05-14' }); + expect(service.preview).toHaveBeenCalledExactlyOnceWith(4, 9, preview); + + const save = { utcOffsetMinutes: 120, expectedHash: HASH, replace: true }; + expect(await controller.save(user, '9', '2026-05-14', save, 'sock-1')).toEqual({ outcome: 'created' }); + expect(service.save).toHaveBeenCalledExactlyOnceWith(4, 9, '2026-05-14', save, 'sock-1'); + + expect(await controller.remove(user, '9', '2026-05-14', 'sock-1')).toEqual({ success: true }); + expect(service.remove).toHaveBeenCalledExactlyOnceWith(4, 9, '2026-05-14', 'sock-1'); + }); + + it('JRNREC-CTL-003: a day that is no calendar day is a 400 before the service is asked', async () => { + const { service, controller } = controllerWith(); + const refusals = await Promise.all( + ['2026-02-30', '14.05.2026', 'today'].map((date) => + controller.remove(user, '9', date).catch((err: unknown) => err), + ), + ); + for (const err of refusals) { + expect(err).toBeInstanceOf(HttpException); + expect((err as HttpException).getStatus()).toBe(400); + expect((err as HttpException).getResponse()).toEqual({ error: 'Invalid date' }); + } + await expect( + controller.save(user, '9', 'nope', { utcOffsetMinutes: 0, expectedHash: HASH }), + ).rejects.toBeInstanceOf(HttpException); + expect(service.remove).not.toHaveBeenCalled(); + expect(service.save).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/journey-recordings.mcp.test.ts b/server/tests/unit/nest/journey-recordings.mcp.test.ts new file mode 100644 index 000000000..6c51da572 --- /dev/null +++ b/server/tests/unit/nest/journey-recordings.mcp.test.ts @@ -0,0 +1,179 @@ +/** + * JourneyRecordingsMcp (JRN-01): the recorded days a journey keeps over MCP, + * on the same service and journey access as REST. The suite takes its subject + * from the container over a snapshot test db; only the read from Dawarich is + * replaced. A tool answers what a day covers and never its geometry. + */ +import { JourneyRecordedDays } from '../../../src/db/entities/JourneyRecordedDays.entity'; +import type { McpContext } from '../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { DawarichTracksService, type RecordedDayFetch } from '../../../src/nest/integrations/dawarich-tracks.service'; +import { JourneyRecordingsMcp } from '../../../src/nest/journey-recordings/journey-recordings.mcp'; +import { JourneyRecordingsModule } from '../../../src/nest/journey-recordings/journey-recordings.module'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { addJourneyContributor, makeJourney } from '../../helpers/factories/journeys'; +import { countRows } from '../../helpers/factories/rows'; +import { setAddonEnabled } from '../../helpers/factories/settings'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +const fetchDay = vi.fn<(...args: unknown[]) => Promise>(); +let t: TestModule; +let orm: TestOrm; +let mcp: JourneyRecordingsMcp; + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [JourneyRecordingsModule], + overrides: [{ provide: DawarichTracksService, useValue: { fetchDay } }], + }); + mcp = t.get(JourneyRecordingsMcp); + orm = await createTestOrm(db); +}); +beforeEach(async () => { + resetTestDb(db); + orm.clear(); + vi.restoreAllMocks(); + fetchDay.mockReset(); + await setAddonEnabled(orm, 'journey', true); + await setAddonEnabled(orm, 'dawarich', true); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +const ctx = (userId: number) => ({ userId }) as McpContext; + +function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +const DAY: RecordedDayFetch = { + segments: [ + { + points: [ + [45.434, 12.3388], + [45.4364, 12.3218], + ], + mode: 'walking', + startedAt: '2026-05-14T09:00:00+02:00', + endedAt: '2026-05-14T09:40:00+02:00', + distanceMeters: 1500, + }, + ], + sourceKind: 'tracks', + utcOffsetMinutes: 120, + offsetSource: 'source', + sourceOffsetMinutes: 120, + window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' }, + fetchedAt: '2026-05-15T07:00:00.000Z', + truncated: false, +}; + +async function fixture() { + const { user: owner } = await makeUser(orm); + const { user: viewer } = await makeUser(orm); + const journey = await makeJourney(orm, owner.id, { title: 'Venice' }); + await addJourneyContributor(orm, journey.id, viewer.id, 'viewer'); + return { owner, viewer, journey }; +} + +describe('JourneyRecordingsMcp', () => { + it('JRNREC-MCP-001: previews, keeps, lists and drops a day, answering without geometry', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(DAY); + + const preview = read( + await mcp.previewRecordedDay( + { journeyId: f.journey.id, date: '2026-05-14', utcOffsetMinutes: 0, useSourceOffset: true }, + ctx(f.owner.id), + ), + ); + expect(preview).toMatchObject({ + journey: { id: f.journey.id, title: 'Venice' }, + utcOffsetMinutes: 120, + stats: { distanceMeters: 1500, segmentCount: 1 }, + stored: null, + audience: 1, + }); + expect(preview.segments).toBeUndefined(); + + const saved = read( + await mcp.saveRecordedDay( + { journeyId: f.journey.id, date: '2026-05-14', utcOffsetMinutes: 120, expectedHash: preview.contentHash }, + ctx(f.owner.id), + ), + ); + expect(saved.outcome).toBe('created'); + expect(saved.day).toMatchObject({ date: '2026-05-14', sourceKind: 'tracks' }); + expect(saved.day.segments).toBeUndefined(); + + const listed = read(await mcp.listRecordedDays({ journeyId: f.journey.id }, ctx(f.viewer.id))); + expect(listed.days).toHaveLength(1); + expect(listed.days[0].segments).toBeUndefined(); + + const again = read( + await mcp.previewRecordedDay( + { journeyId: f.journey.id, date: '2026-05-14', utcOffsetMinutes: 0, useSourceOffset: true }, + ctx(f.owner.id), + ), + ); + expect(again).toMatchObject({ sameAsStored: true, stored: { date: '2026-05-14' } }); + expect(again.stored.segments).toBeUndefined(); + + expect(read(await mcp.deleteRecordedDay({ journeyId: f.journey.id, date: '2026-05-14' }, ctx(f.owner.id)))).toEqual( + { success: true }, + ); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(0); + }); + + it('JRNREC-MCP-002: refuses what REST refuses: a viewer keeps nothing, a stranger reads nothing', async () => { + const f = await fixture(); + const { user: stranger } = await makeUser(orm); + fetchDay.mockResolvedValue(DAY); + + await expect( + mcp.saveRecordedDay( + { journeyId: f.journey.id, date: '2026-05-14', utcOffsetMinutes: 120, expectedHash: 'a'.repeat(64) }, + ctx(f.viewer.id), + ), + ).rejects.toThrow('Not allowed'); + await expect(mcp.listRecordedDays({ journeyId: f.journey.id }, ctx(stranger.id))).rejects.toThrow( + 'Journey not found', + ); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(0); + }); + + it('JRNREC-MCP-003: reading and dropping need the Journey addon, previewing and keeping need Dawarich too', async () => { + const options = (name: string) => { + const entry = getEntry(JourneyRecordingsMcp as unknown as ClassRef, name); + if (entry?.kind !== 'tool') throw new Error(`${name} is no tool`); + return entry.options; + }; + const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(true); + const ALL = ['listRecordedDays', 'deleteRecordedDay', 'previewRecordedDay', 'saveRecordedDay']; + const gates = () => Promise.all(ALL.map((name) => options(name).when?.(ctx(1), mcp))); + expect(await gates()).toEqual([true, true, true, true]); + + enabled.mockImplementation(async (id: string) => id !== 'dawarich'); + expect(await gates()).toEqual([true, true, false, false]); + + enabled.mockImplementation(async (id: string) => id !== 'journey'); + expect(await gates()).toEqual([false, false, false, false]); + + expect(options('listRecordedDays').access).toEqual({ group: 'journey', mode: 'read' }); + expect(options('previewRecordedDay').access).toEqual({ group: 'journey', mode: 'read' }); + expect(options('saveRecordedDay').access).toEqual({ group: 'journey', mode: 'write' }); + expect(options('deleteRecordedDay').access).toEqual({ group: 'journey', mode: 'write' }); + }); +}); diff --git a/server/tests/unit/nest/journey-recordings.service.test.ts b/server/tests/unit/nest/journey-recordings.service.test.ts new file mode 100644 index 000000000..6bd59083b --- /dev/null +++ b/server/tests/unit/nest/journey-recordings.service.test.ts @@ -0,0 +1,352 @@ +/** + * JourneyRecordingsService (JRN-01): taking a day's recorded route over into a + * journey. The suite takes the service from its module over a snapshot test + * db; only the read from Dawarich is replaced, so the journey access, the kept + * rows and the broadcasts are the real ones. + * + * What it pins: a preview writes nothing and says what would be kept, for + * which journey and day and who would see it; a save keeps exactly the day + * that was previewed, once, replaces a different version only when asked and + * never touches an entry; a failed read keeps the version the journey had; + * reading and removing kept days need no Dawarich at all. + */ +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { JourneyRecordedDays } from '../../../src/db/entities/JourneyRecordedDays.entity'; +import { DomainError } from '../../../src/nest/common/domain-error'; +import { DawarichTracksService, type RecordedDayFetch } from '../../../src/nest/integrations/dawarich-tracks.service'; +import { JourneyRecordingsModule } from '../../../src/nest/journey-recordings/journey-recordings.module'; +import { JourneyRecordingsService } from '../../../src/nest/journey-recordings/journey-recordings.service'; +import { JourneyRecordedDaysService } from '../../../src/nest/journey/journey-recorded-days.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys'; +import { countRows, findRow } from '../../helpers/factories/rows'; +import { setAddonEnabled } from '../../helpers/factories/settings'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +const fetchDay = vi.fn<(...args: unknown[]) => Promise>(); +let t: TestModule; +let orm: TestOrm; +let svc: JourneyRecordingsService; +let kept: JourneyRecordedDaysService; + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [JourneyRecordingsModule], + overrides: [{ provide: DawarichTracksService, useValue: { fetchDay } }], + }); + svc = t.get(JourneyRecordingsService); + kept = t.get(JourneyRecordedDaysService); + orm = await createTestOrm(db); +}); +beforeEach(async () => { + resetTestDb(db); + orm.clear(); + fetchDay.mockReset(); + t.realtime.reset(); + await setAddonEnabled(orm, 'journey', true); + await setAddonEnabled(orm, 'dawarich', true); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +const WALK = { + points: [ + [45.434, 12.3388], + [45.4364, 12.3218], + ] as Array<[number, number]>, + mode: 'walking', + startedAt: '2026-05-14T09:00:00+02:00', + endedAt: '2026-05-14T09:40:00+02:00', + distanceMeters: 1500, +}; + +function recorded(over: Partial = {}): RecordedDayFetch { + return { + segments: [WALK], + sourceKind: 'tracks', + utcOffsetMinutes: 120, + offsetSource: 'source', + sourceOffsetMinutes: 120, + window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' }, + fetchedAt: '2026-05-15T07:00:00.000Z', + truncated: false, + ...over, + }; +} + +async function fixture() { + const { user: owner } = await makeUser(orm, { username: 'marta' }); + const { user: editor } = await makeUser(orm, { username: 'jonas' }); + const { user: viewer } = await makeUser(orm); + const { user: outsider } = await makeUser(orm); + const journey = await makeJourney(orm, owner.id, { title: 'Venice' }); + await addJourneyContributor(orm, journey.id, editor.id, 'editor'); + await addJourneyContributor(orm, journey.id, viewer.id, 'viewer'); + return { owner, editor, viewer, outsider, journey }; +} + +const PREVIEW = { date: '2026-05-14', utcOffsetMinutes: 0, useSourceOffset: true }; + +async function refusal(promise: Promise): Promise<{ status: number; body: Record }> { + const err = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(DomainError); + return { status: (err as DomainError).getStatus(), body: (err as DomainError).toBody() }; +} + +describe('JourneyRecordingsService.preview', () => { + it('JRNREC-SVC-001: says which journey, day and recording would be kept, and who would see it, keeping nothing', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + + const preview = await svc.preview(f.owner.id, f.journey.id, PREVIEW); + + expect(fetchDay).toHaveBeenCalledWith(f.owner.id, '2026-05-14', { offsetMinutes: 0, useSourceOffset: true }); + expect(preview).toMatchObject({ + journey: { id: f.journey.id, title: 'Venice' }, + date: '2026-05-14', + utcOffsetMinutes: 120, + offsetSource: 'source', + sourceOffsetMinutes: 120, + sourceKind: 'tracks', + stats: { distanceMeters: 1500, segmentCount: 1, gapCount: 0, modes: ['walking'] }, + provisional: false, + stored: null, + sameAsStored: false, + audience: 2, + }); + expect(preview.contentHash).toMatch(/^[0-9a-f]{64}$/); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(0); + }); + + it('JRNREC-SVC-002: a day with nothing recorded has no hash to keep, and a running day is provisional', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded({ segments: [], fetchedAt: '2026-05-14T12:00:00.000Z' })); + + const preview = await svc.preview(f.owner.id, f.journey.id, PREVIEW); + + expect(preview).toMatchObject({ contentHash: null, provisional: true, stats: { segmentCount: 0 } }); + }); + + it('JRNREC-SVC-003: refuses a stranger with 404, a viewer with 403 and a switched-off Dawarich, before asking it', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + + expect(await refusal(svc.preview(f.outsider.id, f.journey.id, PREVIEW))).toEqual({ + status: 404, + body: { error: 'Journey not found' }, + }); + expect(await refusal(svc.preview(f.viewer.id, f.journey.id, PREVIEW))).toEqual({ + status: 403, + body: { error: 'Not allowed' }, + }); + await setAddonEnabled(orm, 'dawarich', false); + expect(await refusal(svc.preview(f.owner.id, f.journey.id, PREVIEW))).toEqual({ + status: 404, + body: { error: 'Dawarich is not enabled', code: 'addon_disabled' }, + }); + expect(fetchDay).not.toHaveBeenCalled(); + }); + + it('JRNREC-SVC-004: shows the version the journey keeps and whether the live one is the same', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + const first = await svc.preview(f.owner.id, f.journey.id, PREVIEW); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', { + utcOffsetMinutes: 120, + expectedHash: String(first.contentHash), + }); + + const same = await svc.preview(f.editor.id, f.journey.id, PREVIEW); + expect(same).toMatchObject({ sameAsStored: true, stored: { importedBy: { username: 'marta' } } }); + + fetchDay.mockResolvedValue(recorded({ segments: [WALK, { ...WALK, startedAt: '2026-05-14T15:00:00+02:00' }] })); + const grown = await svc.preview(f.editor.id, f.journey.id, PREVIEW); + expect(grown.sameAsStored).toBe(false); + expect(grown.stored?.stats.segmentCount).toBe(1); + }); +}); + +describe('JourneyRecordingsService.save', () => { + async function previewed(userId: number, journeyId: number) { + const preview = await svc.preview(userId, journeyId, PREVIEW); + return { utcOffsetMinutes: preview.utcOffsetMinutes, expectedHash: String(preview.contentHash) }; + } + + it('JRNREC-SVC-005: keeps the previewed day in the zone it was cut in and tells everyone on the journey', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + const body = await previewed(f.editor.id, f.journey.id); + + const result = await svc.save(f.editor.id, f.journey.id, '2026-05-14', body, 'sock-1'); + + expect(fetchDay).toHaveBeenLastCalledWith(f.editor.id, '2026-05-14', { + offsetMinutes: 120, + useSourceOffset: false, + }); + expect(result.outcome).toBe('created'); + expect(result.day).toMatchObject({ + date: '2026-05-14', + source: 'dawarich', + sourceKind: 'tracks', + utcOffsetMinutes: 120, + window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' }, + segments: [WALK], + truncated: false, + provisional: false, + importedBy: { id: f.editor.id, username: 'jonas' }, + }); + const recipients = t.realtime.broadcastToUserMock.mock.calls.map(([userId]) => userId).sort(); + expect(recipients).toEqual([f.owner.id, f.editor.id, f.viewer.id].sort()); + expect(t.realtime.broadcastToUserMock).toHaveBeenCalledWith( + f.owner.id, + { type: 'journey:recording:updated', journeyId: f.journey.id, date: '2026-05-14' }, + 'sock-1', + ); + }); + + it('JRNREC-SVC-006: the same day taken over again keeps one version and announces nothing', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + const body = await previewed(f.owner.id, f.journey.id); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', body); + t.realtime.reset(); + + const again = await svc.save(f.owner.id, f.journey.id, '2026-05-14', body); + + expect(again.outcome).toBe('unchanged'); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(1); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it('JRNREC-SVC-007: a different version replaces the kept one only when asked, and no entry is touched', async () => { + const f = await fixture(); + const entry = await makeJourneyEntry(orm, f.journey.id, f.owner.id, { + entry_date: '2026-05-14', + title: 'Rialto', + story: 'Coffee by the bridge', + }); + fetchDay.mockResolvedValue(recorded()); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', await previewed(f.owner.id, f.journey.id)); + + fetchDay.mockResolvedValue(recorded({ segments: [WALK, { ...WALK, startedAt: '2026-05-14T15:00:00+02:00' }] })); + const body = await previewed(f.owner.id, f.journey.id); + expect(await refusal(svc.save(f.owner.id, f.journey.id, '2026-05-14', body))).toEqual({ + status: 409, + body: { error: 'The journey already keeps another recording of that day', code: 'recording_exists' }, + }); + expect((await kept.find(f.journey.id, '2026-05-14'))?.stats.segmentCount).toBe(1); + + const replaced = await svc.save(f.owner.id, f.journey.id, '2026-05-14', { ...body, replace: true }); + expect(replaced.outcome).toBe('replaced'); + expect(replaced.day.stats.segmentCount).toBe(2); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(1); + expect(await findRow(orm, JourneyEntries, { id: entry.id })).toMatchObject({ + title: 'Rialto', + story: 'Coffee by the bridge', + }); + }); + + it('JRNREC-SVC-008: a recording that changed since the preview is not kept, and the kept version stays', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + const body = await previewed(f.owner.id, f.journey.id); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', body); + + fetchDay.mockResolvedValue(recorded({ segments: [{ ...WALK, distanceMeters: 1800 }] })); + expect(await refusal(svc.save(f.owner.id, f.journey.id, '2026-05-14', { ...body, replace: true }))).toEqual({ + status: 409, + body: { error: 'The recording changed since the preview', code: 'recording_changed' }, + }); + expect((await kept.find(f.journey.id, '2026-05-14'))?.stats.distanceMeters).toBe(1500); + }); + + it('JRNREC-SVC-009: a failed read throws before anything is written, so the kept version stays as it was', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded()); + const body = await previewed(f.owner.id, f.journey.id); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', body); + t.realtime.reset(); + + fetchDay.mockRejectedValue( + new DomainError(502, 'Dawarich answered HTTP 500', { details: { code: 'server_error' } }), + ); + expect(await refusal(svc.save(f.owner.id, f.journey.id, '2026-05-14', { ...body, replace: true }))).toEqual({ + status: 502, + body: { error: 'Dawarich answered HTTP 500', code: 'server_error' }, + }); + expect((await kept.find(f.journey.id, '2026-05-14'))?.contentHash).toBe(body.expectedHash); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it('JRNREC-SVC-010: a day with nothing recorded is refused, and a viewer may not keep one', async () => { + const f = await fixture(); + fetchDay.mockResolvedValue(recorded({ segments: [] })); + const body = { utcOffsetMinutes: 120, expectedHash: 'a'.repeat(64) }; + + expect(await refusal(svc.save(f.owner.id, f.journey.id, '2026-05-14', body))).toEqual({ + status: 422, + body: { error: 'Dawarich recorded nothing on that day', code: 'recording_empty' }, + }); + expect((await refusal(svc.save(f.viewer.id, f.journey.id, '2026-05-14', body))).status).toBe(403); + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(0); + }); +}); + +describe('JourneyRecordingsService.list and remove', () => { + async function keptDay(f: Awaited>) { + fetchDay.mockResolvedValue(recorded()); + const preview = await svc.preview(f.owner.id, f.journey.id, PREVIEW); + await svc.save(f.owner.id, f.journey.id, '2026-05-14', { + utcOffsetMinutes: preview.utcOffsetMinutes, + expectedHash: String(preview.contentHash), + }); + t.realtime.reset(); + } + + it('JRNREC-SVC-011: anyone who can open the journey reads its kept days, also with Dawarich switched off', async () => { + const f = await fixture(); + await keptDay(f); + await setAddonEnabled(orm, 'dawarich', false); + fetchDay.mockReset(); + + const days = await svc.list(f.viewer.id, f.journey.id); + + expect(days.map((day) => day.date)).toEqual(['2026-05-14']); + expect(days[0]?.segments).toEqual([WALK]); + expect(fetchDay).not.toHaveBeenCalled(); + expect((await refusal(svc.list(f.outsider.id, f.journey.id))).status).toBe(404); + }); + + it('JRNREC-SVC-012: an editor removes a kept day without Dawarich and everyone is told; a missing one is a 404', async () => { + const f = await fixture(); + await keptDay(f); + await setAddonEnabled(orm, 'dawarich', false); + + expect((await refusal(svc.remove(f.viewer.id, f.journey.id, '2026-05-14'))).status).toBe(403); + await svc.remove(f.editor.id, f.journey.id, '2026-05-14', 'sock-2'); + + expect(await countRows(orm, JourneyRecordedDays, {})).toBe(0); + expect(t.realtime.broadcastToUserMock).toHaveBeenCalledWith( + f.viewer.id, + { type: 'journey:recording:updated', journeyId: f.journey.id, date: '2026-05-14' }, + 'sock-2', + ); + expect(await refusal(svc.remove(f.editor.id, f.journey.id, '2026-05-14'))).toEqual({ + status: 404, + body: { error: 'No recording kept for that day' }, + }); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 8e42cb802..33722e8b4 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -126,6 +126,9 @@ export default defineConfig({ 'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 }, 'src/nest/integrations/**/*.ts': { statements: 78, branches: 68, functions: 78, lines: 80 }, 'src/nest/journey/**/*.ts': { statements: 91, branches: 84, functions: 88, lines: 93 }, + // New domain in this change (JRN-01), unmeasured: pinned at the 80 floor + // like ferratas until the first full run regenerates it. + 'src/nest/journey-recordings/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 }, 'src/nest/managed/**/*.ts': { statements: 100, branches: 100, functions: 100, lines: 100 }, 'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 }, diff --git a/shared/src/dawarich/dawarich.schema.ts b/shared/src/dawarich/dawarich.schema.ts index f19d0ead9..ce3ad1805 100644 --- a/shared/src/dawarich/dawarich.schema.ts +++ b/shared/src/dawarich/dawarich.schema.ts @@ -10,7 +10,9 @@ import { z } from 'zod'; * TREK plans and interprets.** TREK never starts a location tracker of its own, * never writes into Dawarich, and never keeps a copy of the raw GPS archive — * the track overlay is fetched for the window being looked at and thrown away - * again. + * again. The one exception is asked for: a traveller may take one day's route + * over into a journey, thinned like the overlay and only after a preview they + * confirmed (JRN-01, `journey/journey-recordings.schema.ts`). * * The connection is per user (Settings → Integrations); the global on/off is * the `dawarich` addon. Each user stores their own instance URL and API key, diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 069b80e28..9ab2bfc86 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -741,5 +741,79 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'الطقس', 'journey.entry.suggestion': 'اقتراح', 'journey.editor.addDetails': '+ تفاصيل', + 'journey.recording.mapLine': 'سُجّل في {date}', + 'journey.recording.keptBy': 'تم حفظ المسار المسجّل: {distance}، نقله {name} في {date}.', + 'journey.recording.keptOn': 'تم حفظ المسار المسجّل: {distance}، نُقل في {date}.', + 'journey.recording.keptRunning': 'لم يكن اليوم قد انتهى بعد حينها.', + 'journey.recording.takeOver': 'نقل التسجيل', + 'journey.recording.takeOverDay': 'تسجيل {date}', + 'journey.recording.sub': 'هذا اليوم فقط، من حسابك في Dawarich. لا يُحفظ شيء حتى تؤكد.', + 'journey.recording.zone': 'المنطقة الزمنية لليوم', + 'journey.recording.zoneHint': + 'يمتد اليوم في هذه المنطقة من منتصف الليل إلى منتصف الليل. اختر المنطقة التي كنت تسافر فيها.', + 'journey.recording.zoneSource': 'من Dawarich', + 'journey.recording.zoneDevice': 'هذا الجهاز', + 'journey.recording.scope': 'التسجيل', + 'journey.recording.loading': 'جارٍ قراءة اليوم من Dawarich…', + 'journey.recording.keptStays': 'يبقى المسار المحفوظ في هذه الرحلة كما هو.', + 'journey.recording.retry': 'أعد المحاولة', + 'journey.recording.empty': 'لم يسجّل Dawarich شيئًا في هذا اليوم في هذه المنطقة الزمنية.', + 'journey.recording.span': 'من {from} إلى {to}', + 'journey.recording.pieces': '{count} مقطع', + 'journey.recording.pieces.zero': 'لا مقاطع', + 'journey.recording.pieces.one': 'مقطع واحد', + 'journey.recording.pieces.two': 'مقطعان', + 'journey.recording.pieces.few': '{count} مقاطع', + 'journey.recording.pieces.many': '{count} مقطعًا', + 'journey.recording.gaps': '{count} انقطاع', + 'journey.recording.gaps.zero': 'لا انقطاعات', + 'journey.recording.gaps.one': 'انقطاع واحد', + 'journey.recording.gaps.two': 'انقطاعان', + 'journey.recording.gaps.few': '{count} انقطاعات', + 'journey.recording.gaps.many': '{count} انقطاعًا', + 'journey.recording.gapsHint': 'حيث يتوقف التسجيل يتوقف الخط أيضًا. لا يُرسم أي طريق لم يُسجَّل.', + 'journey.recording.sourceHint': + 'المسارات هي طرق قسّمها Dawarich حسب وسيلة التنقل. النقاط هي مواقعه الخام، مخففة إلى 600 في اليوم.', + 'journey.recording.sourceTracks': 'مسارات', + 'journey.recording.sourcePoints': 'نقاط', + 'journey.recording.changedSincePreview': 'تغيّر التسجيل في Dawarich أثناء مشاهدتك له. راجعه مرة أخرى ثم احفظه.', + 'journey.recording.truncated': 'أرسل Dawarich جزءًا فقط من هذا اليوم، فالمسار غير مكتمل.', + 'journey.recording.provisional': + 'لم ينتهِ هذا اليوم بعد في هذه المنطقة الزمنية. يمكنك لاحقًا استبدال المسار باليوم كاملًا.', + 'journey.recording.same': 'هذه الرحلة تحفظ هذا التسجيل نفسه بالفعل.', + 'journey.recording.differs': + 'هذه الرحلة تحفظ نسخة أخرى من {date} ({distance}). الاستبدال لا يغيّر أي إدخالات أو نصوص.', + 'journey.recording.audience': 'سيرى {count} شخص آخر في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.audience.zero': 'لن يرى أحد آخر في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.audience.one': 'سيرى شخص آخر واحد في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.audience.two': 'سيرى شخصان آخران في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.audience.few': + 'سيرى {count} أشخاص آخرين في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.audience.many': + 'سيرى {count} شخصًا آخر في هذه الرحلة المسار على خريطتها. الروابط المشتركة لا تعرضه.', + 'journey.recording.replace': 'استبدال', + 'journey.recording.alreadyKept': 'محفوظ بالفعل', + 'journey.recording.keep': 'حفظ المسار', + 'journey.recording.remove': 'إزالة المسار', + 'journey.recording.removeTitle': 'إزالة المسار المحفوظ؟', + 'journey.recording.removeMessage': + 'يختفي مسار هذا اليوم من خريطة الرحلة. تبقى الإدخالات والنصوص، ويحتفظ Dawarich بتسجيله الخاص.', + 'journey.recording.keptToast': 'تم حفظ المسار في الرحلة', + 'journey.recording.replacedToast': 'تم استبدال المسار', + 'journey.recording.removedToast': 'تمت إزالة المسار من الرحلة', + 'journey.recording.error.recording_exists': 'هذه الرحلة تحفظ بالفعل تسجيلًا آخر لذلك اليوم.', + 'journey.recording.error.recording_changed': 'تغيّر التسجيل منذ المعاينة.', + 'journey.recording.error.recording_empty': 'لم يسجّل Dawarich شيئًا في ذلك اليوم.', + 'journey.recording.mode.walking': 'مشيًا', + 'journey.recording.mode.running': 'جري', + 'journey.recording.mode.cycling': 'دراجة', + 'journey.recording.mode.driving': 'سيارة', + 'journey.recording.mode.motorcycle': 'دراجة نارية', + 'journey.recording.mode.bus': 'حافلة', + 'journey.recording.mode.train': 'قطار', + 'journey.recording.mode.flying': 'رحلة جوية', + 'journey.recording.mode.boat': 'قارب', + 'journey.recording.mode.stationary': 'ثابت', + 'journey.recording.mode.unknown': 'وسيلة غير معروفة', }; export default journey; diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts index f6f855de0..8e26295d9 100644 --- a/shared/src/i18n/az/journey.ts +++ b/shared/src/i18n/az/journey.ts @@ -778,5 +778,68 @@ const journey: TranslationStrings = { 'journey.entry.suggestion': 'Təklif', 'journey.editor.addDetails': '+ Təfərrüatlar', + 'journey.recording.mapLine': '{date} tarixində qeydə alınıb', + 'journey.recording.keptBy': + 'Qeydə alınmış marşrut saxlanıb: {distance}, {name} tərəfindən {date} tarixində götürülüb.', + 'journey.recording.keptOn': 'Qeydə alınmış marşrut saxlanıb: {distance}, {date} tarixində götürülüb.', + 'journey.recording.keptRunning': 'O vaxt gün hələ bitməmişdi.', + 'journey.recording.takeOver': 'Qeydi götür', + 'journey.recording.takeOverDay': '{date} qeydi', + 'journey.recording.sub': 'Yalnız bu gün, sənin Dawarich hesabından. Təsdiqləyənə qədər heç nə saxlanmır.', + 'journey.recording.zone': 'Günün saat qurşağı', + 'journey.recording.zoneHint': + 'Gün bu qurşaqda gecə yarısından gecə yarısına qədər davam edir. Səyahət etdiyin qurşağı seç.', + 'journey.recording.zoneSource': 'Dawarich-dən', + 'journey.recording.zoneDevice': 'Bu cihaz', + 'journey.recording.scope': 'Qeyd', + 'journey.recording.loading': 'Gün Dawarich-dən oxunur…', + 'journey.recording.keptStays': 'Bu səyahətin saxladığı marşrut olduğu kimi qalır.', + 'journey.recording.retry': 'Yenidən cəhd et', + 'journey.recording.empty': 'Dawarich bu gün bu saat qurşağında heç nə qeydə almayıb.', + 'journey.recording.span': '{from} ilə {to} arası', + 'journey.recording.pieces': '{count} hissə', + 'journey.recording.pieces.one': '{count} hissə', + 'journey.recording.gaps': '{count} boşluq', + 'journey.recording.gaps.one': '{count} boşluq', + 'journey.recording.gapsHint': 'Qeyd dayandığı yerdə xətt də dayanır. Qeydə alınmamış heç bir yol çəkilmir.', + 'journey.recording.sourceHint': + 'İzlər Dawarich-in artıq hərəkət növünə görə böldüyü marşrutlardır. Nöqtələr xam mövqelərdir, gündə 600-ə qədər seyrəldilir.', + 'journey.recording.sourceTracks': 'İzlər', + 'journey.recording.sourcePoints': 'Nöqtələr', + 'journey.recording.changedSincePreview': 'Sən baxarkən qeyd Dawarich-də dəyişdi. Bir daha yoxla, sonra saxla.', + 'journey.recording.truncated': 'Dawarich bu günün yalnız bir hissəsini göndərdi, marşrut natamamdır.', + 'journey.recording.provisional': + 'Bu gün bu saat qurşağında hələ bitməyib. Marşrutu sonra bütün günlə əvəz edə bilərsən.', + 'journey.recording.same': 'Bu səyahət artıq məhz bu qeydi saxlayır.', + 'journey.recording.differs': + 'Bu səyahət {date} tarixli başqa versiyanı saxlayır ({distance}). Əvəz etmək heç bir qeydi və ya mətni dəyişmir.', + 'journey.recording.audience': + 'Bu səyahətdəki daha {count} nəfər marşrutu xəritədə görəcək. Paylaşılan keçidlər onu göstərmir.', + 'journey.recording.audience.one': + 'Bu səyahətdəki daha {count} nəfər marşrutu xəritədə görəcək. Paylaşılan keçidlər onu göstərmir.', + 'journey.recording.replace': 'Əvəz et', + 'journey.recording.alreadyKept': 'Artıq saxlanıb', + 'journey.recording.keep': 'Marşrutu saxla', + 'journey.recording.remove': 'Marşrutu sil', + 'journey.recording.removeTitle': 'Saxlanmış marşrut silinsin?', + 'journey.recording.removeMessage': + 'Bu günün marşrutu səyahət xəritəsindən çıxır. Qeydlər və mətnlər qalır, Dawarich isə öz qeydini saxlayır.', + 'journey.recording.keptToast': 'Marşrut səyahətdə saxlanıldı', + 'journey.recording.replacedToast': 'Marşrut əvəz edildi', + 'journey.recording.removedToast': 'Marşrut səyahətdən silindi', + 'journey.recording.error.recording_exists': 'Bu səyahət o gün üçün artıq başqa qeyd saxlayır.', + 'journey.recording.error.recording_changed': 'Qeyd önbaxışdan sonra dəyişib.', + 'journey.recording.error.recording_empty': 'Dawarich o gün heç nə qeydə almayıb.', + 'journey.recording.mode.walking': 'Piyada', + 'journey.recording.mode.running': 'Qaçış', + 'journey.recording.mode.cycling': 'Velosiped', + 'journey.recording.mode.driving': 'Avtomobil', + 'journey.recording.mode.motorcycle': 'Motosiklet', + 'journey.recording.mode.bus': 'Avtobus', + 'journey.recording.mode.train': 'Qatar', + 'journey.recording.mode.flying': 'Uçuş', + 'journey.recording.mode.boat': 'Qayıq', + 'journey.recording.mode.stationary': 'Hərəkətsiz', + 'journey.recording.mode.unknown': 'Naməlum növ', }; export default journey; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index acc2ccd59..324b19b54 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -705,5 +705,69 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Clima', 'journey.entry.suggestion': 'Sugestão', 'journey.editor.addDetails': '+ Detalhes', + 'journey.recording.mapLine': 'Gravado em {date}', + 'journey.recording.keptBy': 'Rota gravada salva: {distance}, trazida por {name} em {date}.', + 'journey.recording.keptOn': 'Rota gravada salva: {distance}, trazida em {date}.', + 'journey.recording.keptRunning': 'O dia ainda não tinha terminado.', + 'journey.recording.takeOver': 'Trazer a gravação', + 'journey.recording.takeOverDay': 'Gravação de {date}', + 'journey.recording.sub': 'Só este dia, do seu Dawarich. Nada é salvo até você confirmar.', + 'journey.recording.zone': 'Fuso horário do dia', + 'journey.recording.zoneHint': + 'O dia vai de meia-noite a meia-noite neste fuso. Escolha aquele em que você estava viajando.', + 'journey.recording.zoneSource': 'Do Dawarich', + 'journey.recording.zoneDevice': 'Este aparelho', + 'journey.recording.scope': 'Gravação', + 'journey.recording.loading': 'Lendo o dia no Dawarich…', + 'journey.recording.keptStays': 'A rota que esta viagem guarda continua como está.', + 'journey.recording.retry': 'Tentar de novo', + 'journey.recording.empty': 'O Dawarich não gravou nada neste dia neste fuso horário.', + 'journey.recording.span': 'das {from} às {to}', + 'journey.recording.pieces': '{count} trechos', + 'journey.recording.pieces.one': '{count} trecho', + 'journey.recording.gaps': '{count} interrupções', + 'journey.recording.gaps.one': '{count} interrupção', + 'journey.recording.gapsHint': + 'Onde a gravação para, a linha também para. Nenhum caminho que não foi gravado é desenhado.', + 'journey.recording.sourceHint': + 'Trilhas são rotas que o Dawarich já dividiu por meio de transporte. Pontos são as posições brutas, reduzidas a 600 por dia.', + 'journey.recording.sourceTracks': 'Trilhas', + 'journey.recording.sourcePoints': 'Pontos', + 'journey.recording.changedSincePreview': + 'A gravação mudou no Dawarich enquanto você olhava. Confira mais uma vez e depois salve.', + 'journey.recording.truncated': 'O Dawarich enviou só uma parte deste dia, a rota está incompleta.', + 'journey.recording.provisional': + 'Este dia ainda não acabou neste fuso. Depois você pode substituir a rota pelo dia inteiro.', + 'journey.recording.same': 'Esta viagem já guarda exatamente esta gravação.', + 'journey.recording.differs': + 'Esta viagem guarda outra versão de {date} ({distance}). Substituir não muda nenhuma entrada nem texto.', + 'journey.recording.audience': + 'Mais {count} pessoas desta viagem verão a rota no mapa. Links compartilhados não a mostram.', + 'journey.recording.audience.one': + 'Mais {count} pessoa desta viagem verá a rota no mapa. Links compartilhados não a mostram.', + 'journey.recording.replace': 'Substituir', + 'journey.recording.alreadyKept': 'Já salva', + 'journey.recording.keep': 'Salvar rota', + 'journey.recording.remove': 'Remover rota', + 'journey.recording.removeTitle': 'Remover a rota salva?', + 'journey.recording.removeMessage': + 'A rota deste dia sai do mapa da viagem. Entradas e textos ficam, e o Dawarich mantém sua própria gravação.', + 'journey.recording.keptToast': 'Rota salva na viagem', + 'journey.recording.replacedToast': 'Rota substituída', + 'journey.recording.removedToast': 'Rota removida da viagem', + 'journey.recording.error.recording_exists': 'Esta viagem já guarda outra gravação daquele dia.', + 'journey.recording.error.recording_changed': 'A gravação mudou desde a prévia.', + 'journey.recording.error.recording_empty': 'O Dawarich não gravou nada naquele dia.', + 'journey.recording.mode.walking': 'A pé', + 'journey.recording.mode.running': 'Corrida', + 'journey.recording.mode.cycling': 'Bicicleta', + 'journey.recording.mode.driving': 'Carro', + 'journey.recording.mode.motorcycle': 'Moto', + 'journey.recording.mode.bus': 'Ônibus', + 'journey.recording.mode.train': 'Trem', + 'journey.recording.mode.flying': 'Voo', + 'journey.recording.mode.boat': 'Barco', + 'journey.recording.mode.stationary': 'Parado', + 'journey.recording.mode.unknown': 'Meio desconhecido', }; export default journey; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index fcf942aa6..5bc984362 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -708,5 +708,68 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Temps', 'journey.entry.suggestion': 'Suggeriment', 'journey.editor.addDetails': '+ Detalls', + 'journey.recording.mapLine': 'Enregistrat el {date}', + 'journey.recording.keptBy': 'Ruta enregistrada desada: {distance}, presa per {name} el {date}.', + 'journey.recording.keptOn': 'Ruta enregistrada desada: {distance}, presa el {date}.', + 'journey.recording.keptRunning': 'El dia encara no s’havia acabat.', + 'journey.recording.takeOver': 'Agafar l’enregistrament', + 'journey.recording.takeOverDay': 'Enregistrament del {date}', + 'journey.recording.sub': 'Només aquest dia, des del teu Dawarich. No es desa res fins que confirmis.', + 'journey.recording.zone': 'Fus horari del dia', + 'journey.recording.zoneHint': 'El dia va de mitjanit a mitjanit en aquest fus. Tria el fus on viatjaves.', + 'journey.recording.zoneSource': 'Segons Dawarich', + 'journey.recording.zoneDevice': 'Aquest dispositiu', + 'journey.recording.scope': 'Enregistrament', + 'journey.recording.loading': 'S’està llegint el dia de Dawarich…', + 'journey.recording.keptStays': 'La ruta que desa aquest viatge es queda com està.', + 'journey.recording.retry': 'Torna-ho a provar', + 'journey.recording.empty': 'Dawarich no va enregistrar res aquell dia en aquest fus horari.', + 'journey.recording.span': 'de {from} a {to}', + 'journey.recording.pieces': '{count} trams', + 'journey.recording.pieces.one': '{count} tram', + 'journey.recording.gaps': '{count} interrupcions', + 'journey.recording.gaps.one': '{count} interrupció', + 'journey.recording.gapsHint': + 'On l’enregistrament s’atura, la línia també. No es dibuixa cap camí que no s’hagi enregistrat.', + 'journey.recording.sourceHint': + 'Les rutes són trajectes que Dawarich ja ha dividit per mitjà. Els punts són les seves posicions en brut, reduïdes a 600 per dia.', + 'journey.recording.sourceTracks': 'Rutes', + 'journey.recording.sourcePoints': 'Punts', + 'journey.recording.changedSincePreview': + 'L’enregistrament ha canviat a Dawarich mentre el miraves. Revisa’l un cop més i després desa’l.', + 'journey.recording.truncated': 'Dawarich només ha enviat una part d’aquest dia, la ruta és incompleta.', + 'journey.recording.provisional': + 'Aquest dia encara no s’ha acabat en aquest fus. Més endavant pots substituir la ruta pel dia sencer.', + 'journey.recording.same': 'Aquest viatge ja desa exactament aquest enregistrament.', + 'journey.recording.differs': + 'Aquest viatge desa una altra versió del {date} ({distance}). Substituir-la no canvia cap entrada ni cap text.', + 'journey.recording.audience': + '{count} persones més d’aquest viatge veuran la ruta al mapa. Els enllaços compartits no la mostren.', + 'journey.recording.audience.one': + '{count} persona més d’aquest viatge veurà la ruta al mapa. Els enllaços compartits no la mostren.', + 'journey.recording.replace': 'Substitueix', + 'journey.recording.alreadyKept': 'Ja desada', + 'journey.recording.keep': 'Desa la ruta', + 'journey.recording.remove': 'Treu la ruta', + 'journey.recording.removeTitle': 'Vols treure la ruta desada?', + 'journey.recording.removeMessage': + 'La ruta d’aquest dia desapareix del mapa del viatge. Les entrades i els textos es queden, i Dawarich conserva el seu propi enregistrament.', + 'journey.recording.keptToast': 'Ruta desada al viatge', + 'journey.recording.replacedToast': 'Ruta substituïda', + 'journey.recording.removedToast': 'Ruta treta del viatge', + 'journey.recording.error.recording_exists': 'Aquest viatge ja desa un altre enregistrament d’aquell dia.', + 'journey.recording.error.recording_changed': 'L’enregistrament ha canviat des de la previsualització.', + 'journey.recording.error.recording_empty': 'Dawarich no va enregistrar res aquell dia.', + 'journey.recording.mode.walking': 'A peu', + 'journey.recording.mode.running': 'Córrer', + 'journey.recording.mode.cycling': 'Bicicleta', + 'journey.recording.mode.driving': 'Cotxe', + 'journey.recording.mode.motorcycle': 'Moto', + 'journey.recording.mode.bus': 'Autobús', + 'journey.recording.mode.train': 'Tren', + 'journey.recording.mode.flying': 'Vol', + 'journey.recording.mode.boat': 'Vaixell', + 'journey.recording.mode.stationary': 'Aturat', + 'journey.recording.mode.unknown': 'Mitjà desconegut', }; export default journey; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index 9bce80035..79e361b43 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -712,5 +712,70 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Počasí', 'journey.entry.suggestion': 'Návrh', 'journey.editor.addDetails': '+ Podrobnosti', + 'journey.recording.mapLine': 'Zaznamenáno {date}', + 'journey.recording.keptBy': 'Zaznamenaná trasa uložena: {distance}, převzal(a) {name} dne {date}.', + 'journey.recording.keptOn': 'Zaznamenaná trasa uložena: {distance}, převzato dne {date}.', + 'journey.recording.keptRunning': 'Den tehdy ještě neskončil.', + 'journey.recording.takeOver': 'Převzít záznam', + 'journey.recording.takeOverDay': 'Záznam z {date}', + 'journey.recording.sub': 'Jen tento den, z tvého Dawariche. Nic se neuloží, dokud nepotvrdíš.', + 'journey.recording.zone': 'Časové pásmo dne', + 'journey.recording.zoneHint': 'Den trvá v tomto pásmu od půlnoci do půlnoci. Vyber to, ve kterém jsi cestoval(a).', + 'journey.recording.zoneSource': 'Z Dawariche', + 'journey.recording.zoneDevice': 'Toto zařízení', + 'journey.recording.scope': 'Záznam', + 'journey.recording.loading': 'Načítá se den z Dawariche…', + 'journey.recording.keptStays': 'Trasa, kterou tato cesta uchovává, zůstává beze změny.', + 'journey.recording.retry': 'Zkusit znovu', + 'journey.recording.empty': 'Dawarich v tento den v tomto časovém pásmu nic nezaznamenal.', + 'journey.recording.span': '{from} až {to}', + 'journey.recording.pieces': '{count} úseků', + 'journey.recording.pieces.one': '{count} úsek', + 'journey.recording.pieces.few': '{count} úseky', + 'journey.recording.gaps': '{count} mezer', + 'journey.recording.gaps.one': '{count} mezera', + 'journey.recording.gaps.few': '{count} mezery', + 'journey.recording.gapsHint': 'Kde záznam končí, končí i čára. Nekreslí se žádná cesta, která nebyla zaznamenána.', + 'journey.recording.sourceHint': + 'Stopy jsou trasy, které Dawarich už rozdělil podle způsobu dopravy. Body jsou jeho surové polohy, prořídlé na 600 za den.', + 'journey.recording.sourceTracks': 'Stopy', + 'journey.recording.sourcePoints': 'Body', + 'journey.recording.changedSincePreview': + 'Záznam se v Dawarichi změnil, zatímco jsi ho prohlížel(a). Zkontroluj ho ještě jednou a pak ho ulož.', + 'journey.recording.truncated': 'Dawarich poslal jen část tohoto dne, trasa je neúplná.', + 'journey.recording.provisional': 'Tento den v tomto pásmu ještě neskončil. Trasu můžeš později nahradit celým dnem.', + 'journey.recording.same': 'Tato cesta už uchovává přesně tento záznam.', + 'journey.recording.differs': + 'Tato cesta uchovává jinou verzi z {date} ({distance}). Nahrazení nezmění žádné záznamy ani texty.', + 'journey.recording.audience': + 'Dalších {count} lidí na této cestě uvidí trasu na mapě. Sdílené odkazy ji nezobrazují.', + 'journey.recording.audience.one': + 'Další {count} člověk na této cestě uvidí trasu na mapě. Sdílené odkazy ji nezobrazují.', + 'journey.recording.audience.few': + 'Další {count} lidé na této cestě uvidí trasu na mapě. Sdílené odkazy ji nezobrazují.', + 'journey.recording.replace': 'Nahradit', + 'journey.recording.alreadyKept': 'Už uloženo', + 'journey.recording.keep': 'Uložit trasu', + 'journey.recording.remove': 'Odebrat trasu', + 'journey.recording.removeTitle': 'Odebrat uloženou trasu?', + 'journey.recording.removeMessage': + 'Trasa tohoto dne zmizí z mapy cesty. Záznamy a texty zůstanou a Dawarich si ponechá vlastní záznam.', + 'journey.recording.keptToast': 'Trasa uložena do cesty', + 'journey.recording.replacedToast': 'Trasa nahrazena', + 'journey.recording.removedToast': 'Trasa odebrána z cesty', + 'journey.recording.error.recording_exists': 'Tato cesta už pro ten den uchovává jiný záznam.', + 'journey.recording.error.recording_changed': 'Záznam se od náhledu změnil.', + 'journey.recording.error.recording_empty': 'Dawarich v ten den nic nezaznamenal.', + 'journey.recording.mode.walking': 'Pěšky', + 'journey.recording.mode.running': 'Běh', + 'journey.recording.mode.cycling': 'Kolo', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Motorka', + 'journey.recording.mode.bus': 'Autobus', + 'journey.recording.mode.train': 'Vlak', + 'journey.recording.mode.flying': 'Let', + 'journey.recording.mode.boat': 'Loď', + 'journey.recording.mode.stationary': 'Na místě', + 'journey.recording.mode.unknown': 'Neznámý způsob', }; export default journey; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 72ad48565..ace041eca 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -709,5 +709,70 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Wetter', 'journey.entry.suggestion': 'Vorschlag', 'journey.editor.addDetails': '+ Details', + 'journey.recording.mapLine': 'Aufgezeichnet am {date}', + 'journey.recording.keptBy': 'Aufgezeichnete Strecke gespeichert: {distance}, übernommen von {name} am {date}.', + 'journey.recording.keptOn': 'Aufgezeichnete Strecke gespeichert: {distance}, übernommen am {date}.', + 'journey.recording.keptRunning': 'Der Tag lief da noch.', + 'journey.recording.takeOver': 'Aufzeichnung übernehmen', + 'journey.recording.takeOverDay': 'Aufzeichnung vom {date}', + 'journey.recording.sub': 'Nur dieser Tag, aus deinem Dawarich. Gespeichert wird erst, wenn du bestätigst.', + 'journey.recording.zone': 'Zeitzone des Tages', + 'journey.recording.zoneHint': + 'Der Tag geht in dieser Zone von Mitternacht bis Mitternacht. Wähle die, in der du unterwegs warst.', + 'journey.recording.zoneSource': 'Aus Dawarich', + 'journey.recording.zoneDevice': 'Dieses Gerät', + 'journey.recording.scope': 'Aufzeichnung', + 'journey.recording.loading': 'Tag wird aus Dawarich gelesen…', + 'journey.recording.keptStays': 'Die Strecke, die diese Journey gespeichert hat, bleibt unverändert.', + 'journey.recording.retry': 'Erneut versuchen', + 'journey.recording.empty': 'Dawarich hat an diesem Tag in dieser Zeitzone nichts aufgezeichnet.', + 'journey.recording.span': '{from} bis {to}', + 'journey.recording.pieces': '{count} Abschnitte', + 'journey.recording.pieces.one': '{count} Abschnitt', + 'journey.recording.gaps': '{count} Lücken', + 'journey.recording.gaps.one': '{count} Lücke', + 'journey.recording.gapsHint': + 'Wo die Aufzeichnung aussetzt, endet auch die Linie. Es wird kein Weg gezeichnet, der nicht aufgezeichnet wurde.', + 'journey.recording.sourceHint': + 'Strecken sind Abschnitte, die Dawarich schon nach Fortbewegung geteilt hat. Punkte sind seine Rohpositionen, ausgedünnt auf 600 pro Tag.', + 'journey.recording.sourceTracks': 'Strecken', + 'journey.recording.sourcePoints': 'Punkte', + 'journey.recording.changedSincePreview': + 'Die Aufzeichnung hat sich in Dawarich geändert, während du sie angesehen hast. Prüfe sie noch einmal und speichere sie dann.', + 'journey.recording.truncated': 'Dawarich hat nur einen Teil dieses Tages geschickt, die Strecke ist unvollständig.', + 'journey.recording.provisional': + 'Dieser Tag ist in dieser Zeitzone noch nicht vorbei. Du kannst die Strecke später durch den ganzen Tag ersetzen.', + 'journey.recording.same': 'Diese Journey hat genau diese Aufzeichnung schon gespeichert.', + 'journey.recording.differs': + 'Diese Journey hat eine andere Fassung vom {date} gespeichert ({distance}). Ersetzen ändert keine Einträge und Texte.', + 'journey.recording.audience': + '{count} weitere Personen in dieser Journey sehen die Strecke auf der Karte. Geteilte Links zeigen sie nicht.', + 'journey.recording.audience.one': + '{count} weitere Person in dieser Journey sieht die Strecke auf der Karte. Geteilte Links zeigen sie nicht.', + 'journey.recording.replace': 'Ersetzen', + 'journey.recording.alreadyKept': 'Schon gespeichert', + 'journey.recording.keep': 'Strecke speichern', + 'journey.recording.remove': 'Strecke entfernen', + 'journey.recording.removeTitle': 'Gespeicherte Strecke entfernen?', + 'journey.recording.removeMessage': + 'Die Strecke dieses Tages verschwindet von der Journey-Karte. Einträge und Texte bleiben, und Dawarich behält seine eigene Aufzeichnung.', + 'journey.recording.keptToast': 'Strecke in der Journey gespeichert', + 'journey.recording.replacedToast': 'Strecke ersetzt', + 'journey.recording.removedToast': 'Strecke aus der Journey entfernt', + 'journey.recording.error.recording_exists': + 'Diese Journey hat für diesen Tag schon eine andere Aufzeichnung gespeichert.', + 'journey.recording.error.recording_changed': 'Die Aufzeichnung hat sich seit der Vorschau geändert.', + 'journey.recording.error.recording_empty': 'Dawarich hat an diesem Tag nichts aufgezeichnet.', + 'journey.recording.mode.walking': 'Zu Fuß', + 'journey.recording.mode.running': 'Laufen', + 'journey.recording.mode.cycling': 'Fahrrad', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Motorrad', + 'journey.recording.mode.bus': 'Bus', // same-as-en + 'journey.recording.mode.train': 'Zug', + 'journey.recording.mode.flying': 'Flug', + 'journey.recording.mode.boat': 'Boot', + 'journey.recording.mode.stationary': 'Stillstand', + 'journey.recording.mode.unknown': 'Unbekannte Fortbewegung', }; export default journey; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 5371fefbc..ea05552e8 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -702,5 +702,68 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Weather', 'journey.entry.suggestion': 'Suggestion', 'journey.editor.addDetails': '+ Details', + 'journey.recording.mapLine': 'Recorded on {date}', + 'journey.recording.keptBy': 'Recorded route kept: {distance}, taken over by {name} on {date}.', + 'journey.recording.keptOn': 'Recorded route kept: {distance}, taken over on {date}.', + 'journey.recording.keptRunning': 'The day was still running then.', + 'journey.recording.takeOver': 'Take over recording', + 'journey.recording.takeOverDay': 'Recording of {date}', + 'journey.recording.sub': 'Only this day, from your Dawarich. Nothing is kept until you confirm.', + 'journey.recording.zone': 'Time zone of the day', + 'journey.recording.zoneHint': + 'The day runs from midnight to midnight in this zone. Pick the one you were travelling in.', + 'journey.recording.zoneSource': 'From Dawarich', + 'journey.recording.zoneDevice': 'This device', + 'journey.recording.scope': 'Recording', + 'journey.recording.loading': 'Reading the day from Dawarich…', + 'journey.recording.keptStays': 'The route this journey keeps stays as it is.', + 'journey.recording.retry': 'Try again', + 'journey.recording.empty': 'Dawarich recorded nothing on this day in this time zone.', + 'journey.recording.span': '{from} to {to}', + 'journey.recording.pieces': '{count} pieces', + 'journey.recording.pieces.one': '{count} piece', + 'journey.recording.gaps': '{count} gaps', + 'journey.recording.gaps.one': '{count} gap', + 'journey.recording.gapsHint': 'Where the recording stops, the line stops too. No way is drawn that was not recorded.', + 'journey.recording.sourceHint': + 'Tracks are routes Dawarich already split by mode. Points are its raw positions, thinned to 600 a day.', + 'journey.recording.sourceTracks': 'Tracks', + 'journey.recording.sourcePoints': 'Points', + 'journey.recording.changedSincePreview': + 'The recording changed in Dawarich while you were looking. Check it once more, then keep it.', + 'journey.recording.truncated': 'Dawarich sent only part of this day, so the route is incomplete.', + 'journey.recording.provisional': + 'This day is not over yet in this time zone. You can replace the route later with the whole day.', + 'journey.recording.same': 'This journey keeps exactly this recording already.', + 'journey.recording.differs': + 'This journey keeps another version from {date} ({distance}). Replacing it changes no entries or texts.', + 'journey.recording.audience': + '{count} other people on this journey will see the route on its map. Shared links do not show it.', + 'journey.recording.audience.one': + '{count} other person on this journey will see the route on its map. Shared links do not show it.', + 'journey.recording.replace': 'Replace', + 'journey.recording.alreadyKept': 'Already kept', + 'journey.recording.keep': 'Keep route', + 'journey.recording.remove': 'Remove route', + 'journey.recording.removeTitle': 'Remove the kept route?', + 'journey.recording.removeMessage': + 'The route of this day leaves the journey map. Entries and texts stay, and Dawarich keeps its own recording.', + 'journey.recording.keptToast': 'Route kept in the journey', + 'journey.recording.replacedToast': 'Route replaced', + 'journey.recording.removedToast': 'Route removed from the journey', + 'journey.recording.error.recording_exists': 'This journey already keeps another recording of that day.', + 'journey.recording.error.recording_changed': 'The recording changed since the preview.', + 'journey.recording.error.recording_empty': 'Dawarich recorded nothing on that day.', + 'journey.recording.mode.walking': 'Walking', + 'journey.recording.mode.running': 'Running', + 'journey.recording.mode.cycling': 'Cycling', + 'journey.recording.mode.driving': 'Driving', + 'journey.recording.mode.motorcycle': 'Motorcycle', + 'journey.recording.mode.bus': 'Bus', + 'journey.recording.mode.train': 'Train', + 'journey.recording.mode.flying': 'Flight', + 'journey.recording.mode.boat': 'Boat', + 'journey.recording.mode.stationary': 'Standing still', + 'journey.recording.mode.unknown': 'Unknown mode', }; export default journey; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index d3c0370be..f2347e540 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -706,5 +706,68 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Tiempo', 'journey.entry.suggestion': 'Sugerencia', 'journey.editor.addDetails': '+ Detalles', + 'journey.recording.mapLine': 'Grabado el {date}', + 'journey.recording.keptBy': 'Ruta grabada guardada: {distance}, tomada por {name} el {date}.', + 'journey.recording.keptOn': 'Ruta grabada guardada: {distance}, tomada el {date}.', + 'journey.recording.keptRunning': 'El día aún no había terminado.', + 'journey.recording.takeOver': 'Tomar la grabación', + 'journey.recording.takeOverDay': 'Grabación del {date}', + 'journey.recording.sub': 'Solo este día, desde tu Dawarich. No se guarda nada hasta que confirmes.', + 'journey.recording.zone': 'Zona horaria del día', + 'journey.recording.zoneHint': 'El día va de medianoche a medianoche en esta zona. Elige la zona en la que viajabas.', + 'journey.recording.zoneSource': 'Según Dawarich', + 'journey.recording.zoneDevice': 'Este dispositivo', + 'journey.recording.scope': 'Grabación', + 'journey.recording.loading': 'Leyendo el día de Dawarich…', + 'journey.recording.keptStays': 'La ruta que guarda este viaje se queda como está.', + 'journey.recording.retry': 'Reintentar', + 'journey.recording.empty': 'Dawarich no grabó nada ese día en esta zona horaria.', + 'journey.recording.span': 'de {from} a {to}', + 'journey.recording.pieces': '{count} tramos', + 'journey.recording.pieces.one': '{count} tramo', + 'journey.recording.gaps': '{count} interrupciones', + 'journey.recording.gaps.one': '{count} interrupción', + 'journey.recording.gapsHint': + 'Donde la grabación se corta, la línea también. No se dibuja ningún camino que no se haya grabado.', + 'journey.recording.sourceHint': + 'Las rutas son trayectos que Dawarich ya dividió por modo. Los puntos son sus posiciones en bruto, reducidas a 600 por día.', + 'journey.recording.sourceTracks': 'Rutas', + 'journey.recording.sourcePoints': 'Puntos', + 'journey.recording.changedSincePreview': + 'La grabación cambió en Dawarich mientras la mirabas. Revísala otra vez y luego guárdala.', + 'journey.recording.truncated': 'Dawarich solo envió una parte de este día, la ruta está incompleta.', + 'journey.recording.provisional': + 'Este día aún no ha terminado en esta zona horaria. Más tarde puedes sustituir la ruta por el día completo.', + 'journey.recording.same': 'Este viaje ya guarda exactamente esta grabación.', + 'journey.recording.differs': + 'Este viaje guarda otra versión del {date} ({distance}). Sustituirla no cambia ninguna entrada ni texto.', + 'journey.recording.audience': + '{count} personas más de este viaje verán la ruta en su mapa. Los enlaces compartidos no la muestran.', + 'journey.recording.audience.one': + '{count} persona más de este viaje verá la ruta en su mapa. Los enlaces compartidos no la muestran.', + 'journey.recording.replace': 'Sustituir', + 'journey.recording.alreadyKept': 'Ya guardada', + 'journey.recording.keep': 'Guardar ruta', + 'journey.recording.remove': 'Quitar ruta', + 'journey.recording.removeTitle': '¿Quitar la ruta guardada?', + 'journey.recording.removeMessage': + 'La ruta de este día desaparece del mapa del viaje. Las entradas y los textos se quedan, y Dawarich conserva su propia grabación.', + 'journey.recording.keptToast': 'Ruta guardada en el viaje', + 'journey.recording.replacedToast': 'Ruta sustituida', + 'journey.recording.removedToast': 'Ruta quitada del viaje', + 'journey.recording.error.recording_exists': 'Este viaje ya guarda otra grabación de ese día.', + 'journey.recording.error.recording_changed': 'La grabación cambió desde la vista previa.', + 'journey.recording.error.recording_empty': 'Dawarich no grabó nada ese día.', + 'journey.recording.mode.walking': 'A pie', + 'journey.recording.mode.running': 'Correr', + 'journey.recording.mode.cycling': 'Bicicleta', + 'journey.recording.mode.driving': 'Coche', + 'journey.recording.mode.motorcycle': 'Moto', + 'journey.recording.mode.bus': 'Autobús', + 'journey.recording.mode.train': 'Tren', + 'journey.recording.mode.flying': 'Vuelo', + 'journey.recording.mode.boat': 'Barco', + 'journey.recording.mode.stationary': 'Parado', + 'journey.recording.mode.unknown': 'Modo desconocido', }; export default journey; diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts index 6e27ec081..58cb96268 100644 --- a/shared/src/i18n/et/journey.ts +++ b/shared/src/i18n/et/journey.ts @@ -703,5 +703,67 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Ilm', 'journey.entry.suggestion': 'Soovitus', 'journey.editor.addDetails': '+ Üksikasjad', + 'journey.recording.mapLine': 'Salvestatud {date}', + 'journey.recording.keptBy': 'Salvestatud teekond hoitud: {distance}, üle võtnud {name} {date}.', + 'journey.recording.keptOn': 'Salvestatud teekond hoitud: {distance}, üle võetud {date}.', + 'journey.recording.keptRunning': 'Päev oli siis veel pooleli.', + 'journey.recording.takeOver': 'Võta salvestus üle', + 'journey.recording.takeOverDay': 'Salvestus {date}', + 'journey.recording.sub': 'Ainult see päev, sinu Dawarichist. Midagi ei hoita enne, kui kinnitad.', + 'journey.recording.zone': 'Päeva ajavöönd', + 'journey.recording.zoneHint': 'Päev kestab selles vööndis keskööst keskööni. Vali see, kus sa reisisid.', + 'journey.recording.zoneSource': 'Dawarichist', + 'journey.recording.zoneDevice': 'See seade', + 'journey.recording.scope': 'Salvestus', + 'journey.recording.loading': 'Päeva loetakse Dawarichist…', + 'journey.recording.keptStays': 'Teekond, mida see reis hoiab, jääb samaks.', + 'journey.recording.retry': 'Proovi uuesti', + 'journey.recording.empty': 'Dawarich ei salvestanud sel päeval selles ajavööndis midagi.', + 'journey.recording.span': '{from} kuni {to}', + 'journey.recording.pieces': '{count} lõiku', + 'journey.recording.pieces.one': '{count} lõik', + 'journey.recording.gaps': '{count} katkestust', + 'journey.recording.gaps.one': '{count} katkestus', + 'journey.recording.gapsHint': 'Kus salvestus katkeb, katkeb ka joon. Salvestamata teed ei joonistata.', + 'journey.recording.sourceHint': + 'Rajad on teekonnad, mille Dawarich on juba liikumisviisi järgi jaganud. Punktid on töötlemata asukohad, hõrendatud 600-ni päevas.', + 'journey.recording.sourceTracks': 'Rajad', + 'journey.recording.sourcePoints': 'Punktid', + 'journey.recording.changedSincePreview': + 'Salvestus muutus Dawarichis, kui sa seda vaatasid. Kontrolli veel kord ja hoia siis alles.', + 'journey.recording.truncated': 'Dawarich saatis ainult osa sellest päevast, teekond on puudulik.', + 'journey.recording.provisional': + 'See päev pole selles ajavööndis veel läbi. Hiljem saad teekonna terve päevaga asendada.', + 'journey.recording.same': 'See reis hoiab juba täpselt seda salvestust.', + 'journey.recording.differs': + 'See reis hoiab teist versiooni kuupäevast {date} ({distance}). Asendamine ei muuda ühtegi sissekannet ega teksti.', + 'journey.recording.audience': + 'Veel {count} selle reisi inimest näevad teekonda kaardil. Jagatud lingid seda ei näita.', + 'journey.recording.audience.one': + 'Veel {count} selle reisi inimene näeb teekonda kaardil. Jagatud lingid seda ei näita.', + 'journey.recording.replace': 'Asenda', + 'journey.recording.alreadyKept': 'Juba hoitud', + 'journey.recording.keep': 'Hoia teekond', + 'journey.recording.remove': 'Eemalda teekond', + 'journey.recording.removeTitle': 'Kas eemaldada hoitud teekond?', + 'journey.recording.removeMessage': + 'Selle päeva teekond kaob reisi kaardilt. Sissekanded ja tekstid jäävad ning Dawarich hoiab oma salvestust alles.', + 'journey.recording.keptToast': 'Teekond hoitud reisis', + 'journey.recording.replacedToast': 'Teekond asendatud', + 'journey.recording.removedToast': 'Teekond reisist eemaldatud', + 'journey.recording.error.recording_exists': 'See reis hoiab selle päeva kohta juba teist salvestust.', + 'journey.recording.error.recording_changed': 'Salvestus on pärast eelvaadet muutunud.', + 'journey.recording.error.recording_empty': 'Dawarich ei salvestanud sel päeval midagi.', + 'journey.recording.mode.walking': 'Jalgsi', + 'journey.recording.mode.running': 'Jooks', + 'journey.recording.mode.cycling': 'Jalgratas', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Mootorratas', + 'journey.recording.mode.bus': 'Buss', + 'journey.recording.mode.train': 'Rong', + 'journey.recording.mode.flying': 'Lend', + 'journey.recording.mode.boat': 'Paat', + 'journey.recording.mode.stationary': 'Paigal', + 'journey.recording.mode.unknown': 'Tundmatu liikumisviis', }; export default journey; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 405f1a2bd..f78a6d6f1 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -705,5 +705,68 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Météo', 'journey.entry.suggestion': 'Suggestion', 'journey.editor.addDetails': '+ Détails', + 'journey.recording.mapLine': 'Enregistré le {date}', + 'journey.recording.keptBy': 'Itinéraire enregistré conservé : {distance}, repris par {name} le {date}.', + 'journey.recording.keptOn': 'Itinéraire enregistré conservé : {distance}, repris le {date}.', + 'journey.recording.keptRunning': 'La journée n’était pas encore finie.', + 'journey.recording.takeOver': 'Reprendre l’enregistrement', + 'journey.recording.takeOverDay': 'Enregistrement du {date}', + 'journey.recording.sub': 'Seulement ce jour, depuis votre Dawarich. Rien n’est conservé avant votre confirmation.', + 'journey.recording.zone': 'Fuseau horaire du jour', + 'journey.recording.zoneHint': 'Le jour va de minuit à minuit dans ce fuseau. Choisissez celui où vous voyagiez.', + 'journey.recording.zoneSource': 'Selon Dawarich', + 'journey.recording.zoneDevice': 'Cet appareil', + 'journey.recording.scope': 'Enregistrement', + 'journey.recording.loading': 'Lecture du jour dans Dawarich…', + 'journey.recording.keptStays': 'L’itinéraire conservé par ce voyage reste tel quel.', + 'journey.recording.retry': 'Réessayer', + 'journey.recording.empty': 'Dawarich n’a rien enregistré ce jour-là dans ce fuseau horaire.', + 'journey.recording.span': 'de {from} à {to}', + 'journey.recording.pieces': '{count} tronçons', + 'journey.recording.pieces.one': '{count} tronçon', + 'journey.recording.gaps': '{count} interruptions', + 'journey.recording.gaps.one': '{count} interruption', + 'journey.recording.gapsHint': + 'Là où l’enregistrement s’arrête, la ligne s’arrête aussi. Aucun chemin non enregistré n’est tracé.', + 'journey.recording.sourceHint': + 'Les traces sont des trajets que Dawarich a déjà découpés par mode. Les points sont ses positions brutes, réduites à 600 par jour.', + 'journey.recording.sourceTracks': 'Traces', + 'journey.recording.sourcePoints': 'Points', // same-as-en + 'journey.recording.changedSincePreview': + 'L’enregistrement a changé dans Dawarich pendant que vous le regardiez. Vérifiez-le encore une fois, puis conservez-le.', + 'journey.recording.truncated': 'Dawarich n’a envoyé qu’une partie de ce jour, l’itinéraire est incomplet.', + 'journey.recording.provisional': + 'Ce jour n’est pas encore terminé dans ce fuseau. Vous pourrez remplacer l’itinéraire plus tard par la journée entière.', + 'journey.recording.same': 'Ce voyage conserve déjà exactement cet enregistrement.', + 'journey.recording.differs': + 'Ce voyage conserve une autre version du {date} ({distance}). La remplacer ne modifie aucune entrée ni aucun texte.', + 'journey.recording.audience': + '{count} autres personnes de ce voyage verront l’itinéraire sur sa carte. Les liens partagés ne le montrent pas.', + 'journey.recording.audience.one': + '{count} autre personne de ce voyage verra l’itinéraire sur sa carte. Les liens partagés ne le montrent pas.', + 'journey.recording.replace': 'Remplacer', + 'journey.recording.alreadyKept': 'Déjà conservé', + 'journey.recording.keep': 'Conserver l’itinéraire', + 'journey.recording.remove': 'Retirer l’itinéraire', + 'journey.recording.removeTitle': 'Retirer l’itinéraire conservé ?', + 'journey.recording.removeMessage': + 'L’itinéraire de ce jour disparaît de la carte du voyage. Les entrées et les textes restent, et Dawarich garde son propre enregistrement.', + 'journey.recording.keptToast': 'Itinéraire conservé dans le voyage', + 'journey.recording.replacedToast': 'Itinéraire remplacé', + 'journey.recording.removedToast': 'Itinéraire retiré du voyage', + 'journey.recording.error.recording_exists': 'Ce voyage conserve déjà un autre enregistrement de ce jour.', + 'journey.recording.error.recording_changed': 'L’enregistrement a changé depuis l’aperçu.', + 'journey.recording.error.recording_empty': 'Dawarich n’a rien enregistré ce jour-là.', + 'journey.recording.mode.walking': 'À pied', + 'journey.recording.mode.running': 'Course', + 'journey.recording.mode.cycling': 'Vélo', + 'journey.recording.mode.driving': 'Voiture', + 'journey.recording.mode.motorcycle': 'Moto', + 'journey.recording.mode.bus': 'Bus', // same-as-en + 'journey.recording.mode.train': 'Train', // same-as-en + 'journey.recording.mode.flying': 'Vol', + 'journey.recording.mode.boat': 'Bateau', + 'journey.recording.mode.stationary': 'À l’arrêt', + 'journey.recording.mode.unknown': 'Mode inconnu', }; export default journey; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 931dff69b..987903ef2 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -714,5 +714,69 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Καιρός', 'journey.entry.suggestion': 'Πρόταση', 'journey.editor.addDetails': '+ Λεπτομέρειες', + 'journey.recording.mapLine': 'Καταγράφηκε στις {date}', + 'journey.recording.keptBy': 'Καταγεγραμμένη διαδρομή αποθηκεύτηκε: {distance}, από {name} στις {date}.', + 'journey.recording.keptOn': 'Καταγεγραμμένη διαδρομή αποθηκεύτηκε: {distance}, στις {date}.', + 'journey.recording.keptRunning': 'Η μέρα δεν είχε τελειώσει ακόμη.', + 'journey.recording.takeOver': 'Μεταφορά καταγραφής', + 'journey.recording.takeOverDay': 'Καταγραφή της {date}', + 'journey.recording.sub': 'Μόνο αυτή η μέρα, από το Dawarich σου. Τίποτα δεν αποθηκεύεται πριν το επιβεβαιώσεις.', + 'journey.recording.zone': 'Ζώνη ώρας της μέρας', + 'journey.recording.zoneHint': + 'Η μέρα διαρκεί από μεσάνυχτα σε μεσάνυχτα σε αυτή τη ζώνη. Διάλεξε εκείνη στην οποία ταξίδευες.', + 'journey.recording.zoneSource': 'Από το Dawarich', + 'journey.recording.zoneDevice': 'Αυτή η συσκευή', + 'journey.recording.scope': 'Καταγραφή', + 'journey.recording.loading': 'Ανάγνωση της μέρας από το Dawarich…', + 'journey.recording.keptStays': 'Η διαδρομή που κρατά αυτό το ταξίδι μένει ως έχει.', + 'journey.recording.retry': 'Δοκίμασε ξανά', + 'journey.recording.empty': 'Το Dawarich δεν κατέγραψε τίποτα αυτή τη μέρα σε αυτή τη ζώνη ώρας.', + 'journey.recording.span': '{from} έως {to}', + 'journey.recording.pieces': '{count} τμήματα', + 'journey.recording.pieces.one': '{count} τμήμα', + 'journey.recording.gaps': '{count} κενά', + 'journey.recording.gaps.one': '{count} κενό', + 'journey.recording.gapsHint': + 'Όπου σταματά η καταγραφή, σταματά και η γραμμή. Δεν σχεδιάζεται δρόμος που δεν καταγράφηκε.', + 'journey.recording.sourceHint': + 'Οι διαδρομές είναι τμήματα που το Dawarich έχει ήδη χωρίσει ανά μέσο. Τα σημεία είναι οι ακατέργαστες θέσεις, αραιωμένες σε 600 τη μέρα.', + 'journey.recording.sourceTracks': 'Διαδρομές', + 'journey.recording.sourcePoints': 'Σημεία', + 'journey.recording.changedSincePreview': + 'Η καταγραφή άλλαξε στο Dawarich ενώ την κοίταζες. Έλεγξέ τη άλλη μία φορά και μετά αποθήκευσέ τη.', + 'journey.recording.truncated': 'Το Dawarich έστειλε μόνο μέρος αυτής της μέρας, η διαδρομή είναι ελλιπής.', + 'journey.recording.provisional': + 'Αυτή η μέρα δεν έχει τελειώσει ακόμη σε αυτή τη ζώνη. Μπορείς αργότερα να αντικαταστήσεις τη διαδρομή με ολόκληρη τη μέρα.', + 'journey.recording.same': 'Αυτό το ταξίδι κρατά ήδη ακριβώς αυτή την καταγραφή.', + 'journey.recording.differs': + 'Αυτό το ταξίδι κρατά άλλη έκδοση από {date} ({distance}). Η αντικατάσταση δεν αλλάζει καταχωρίσεις ή κείμενα.', + 'journey.recording.audience': + 'Άλλα {count} άτομα αυτού του ταξιδιού θα δουν τη διαδρομή στον χάρτη. Οι κοινόχρηστοι σύνδεσμοι δεν τη δείχνουν.', + 'journey.recording.audience.one': + 'Άλλο {count} άτομο αυτού του ταξιδιού θα δει τη διαδρομή στον χάρτη. Οι κοινόχρηστοι σύνδεσμοι δεν τη δείχνουν.', + 'journey.recording.replace': 'Αντικατάσταση', + 'journey.recording.alreadyKept': 'Ήδη αποθηκευμένη', + 'journey.recording.keep': 'Αποθήκευση διαδρομής', + 'journey.recording.remove': 'Αφαίρεση διαδρομής', + 'journey.recording.removeTitle': 'Αφαίρεση της αποθηκευμένης διαδρομής;', + 'journey.recording.removeMessage': + 'Η διαδρομή αυτής της μέρας φεύγει από τον χάρτη του ταξιδιού. Οι καταχωρίσεις και τα κείμενα μένουν, και το Dawarich κρατά τη δική του καταγραφή.', + 'journey.recording.keptToast': 'Η διαδρομή αποθηκεύτηκε στο ταξίδι', + 'journey.recording.replacedToast': 'Η διαδρομή αντικαταστάθηκε', + 'journey.recording.removedToast': 'Η διαδρομή αφαιρέθηκε από το ταξίδι', + 'journey.recording.error.recording_exists': 'Αυτό το ταξίδι κρατά ήδη άλλη καταγραφή εκείνης της μέρας.', + 'journey.recording.error.recording_changed': 'Η καταγραφή άλλαξε από την προεπισκόπηση.', + 'journey.recording.error.recording_empty': 'Το Dawarich δεν κατέγραψε τίποτα εκείνη τη μέρα.', + 'journey.recording.mode.walking': 'Με τα πόδια', + 'journey.recording.mode.running': 'Τρέξιμο', + 'journey.recording.mode.cycling': 'Ποδήλατο', + 'journey.recording.mode.driving': 'Αυτοκίνητο', + 'journey.recording.mode.motorcycle': 'Μοτοσικλέτα', + 'journey.recording.mode.bus': 'Λεωφορείο', + 'journey.recording.mode.train': 'Τρένο', + 'journey.recording.mode.flying': 'Πτήση', + 'journey.recording.mode.boat': 'Πλοίο', + 'journey.recording.mode.stationary': 'Ακίνητος', + 'journey.recording.mode.unknown': 'Άγνωστο μέσο', }; export default journey; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index a75296657..ca6bd9848 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -703,5 +703,68 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Időjárás', 'journey.entry.suggestion': 'Javaslat', 'journey.editor.addDetails': '+ Részletek', + 'journey.recording.mapLine': 'Rögzítve: {date}', + 'journey.recording.keptBy': 'Rögzített útvonal elmentve: {distance}, átvette {name}, {date}.', + 'journey.recording.keptOn': 'Rögzített útvonal elmentve: {distance}, átvéve: {date}.', + 'journey.recording.keptRunning': 'A nap akkor még tartott.', + 'journey.recording.takeOver': 'Felvétel átvétele', + 'journey.recording.takeOverDay': 'Felvétel: {date}', + 'journey.recording.sub': 'Csak ez a nap, a Dawarichodból. Semmi sem mentődik, amíg meg nem erősíted.', + 'journey.recording.zone': 'A nap időzónája', + 'journey.recording.zoneHint': 'A nap ebben a zónában éjféltől éjfélig tart. Válaszd azt, amelyikben utaztál.', + 'journey.recording.zoneSource': 'A Dawarichból', + 'journey.recording.zoneDevice': 'Ez az eszköz', + 'journey.recording.scope': 'Felvétel', + 'journey.recording.loading': 'A nap beolvasása a Dawarichból…', + 'journey.recording.keptStays': 'Az utazásban elmentett útvonal változatlan marad.', + 'journey.recording.retry': 'Újra', + 'journey.recording.empty': 'A Dawarich ezen a napon ebben az időzónában nem rögzített semmit.', + 'journey.recording.span': '{from} és {to} között', + 'journey.recording.pieces': '{count} szakasz', + 'journey.recording.pieces.one': '{count} szakasz', + 'journey.recording.gaps': '{count} kimaradás', + 'journey.recording.gaps.one': '{count} kimaradás', + 'journey.recording.gapsHint': + 'Ahol a felvétel megszakad, ott a vonal is. Nem rajzolódik ki olyan út, amelyet nem rögzítettek.', + 'journey.recording.sourceHint': + 'A nyomvonalakat a Dawarich már közlekedési mód szerint felosztotta. A pontok a nyers pozíciók, napi 600-ra ritkítva.', + 'journey.recording.sourceTracks': 'Nyomvonalak', + 'journey.recording.sourcePoints': 'Pontok', + 'journey.recording.changedSincePreview': + 'A felvétel megváltozott a Dawarichban, miközben nézted. Nézd át még egyszer, aztán mentsd el.', + 'journey.recording.truncated': 'A Dawarich csak a nap egy részét küldte el, az útvonal hiányos.', + 'journey.recording.provisional': + 'Ez a nap ebben az időzónában még nem ért véget. Később lecserélheted az útvonalat az egész napra.', + 'journey.recording.same': 'Ez az utazás már pontosan ezt a felvételt tárolja.', + 'journey.recording.differs': + 'Ez az utazás egy másik változatot tárol ({date}, {distance}). A csere nem módosít bejegyzést vagy szöveget.', + 'journey.recording.audience': + 'Az utazás további {count} résztvevője látja az útvonalat a térképen. A megosztott linkek nem mutatják.', + 'journey.recording.audience.one': + 'Az utazás további {count} résztvevője látja az útvonalat a térképen. A megosztott linkek nem mutatják.', + 'journey.recording.replace': 'Csere', + 'journey.recording.alreadyKept': 'Már elmentve', + 'journey.recording.keep': 'Útvonal mentése', + 'journey.recording.remove': 'Útvonal eltávolítása', + 'journey.recording.removeTitle': 'Eltávolítod az elmentett útvonalat?', + 'journey.recording.removeMessage': + 'A nap útvonala eltűnik az utazás térképéről. A bejegyzések és szövegek maradnak, a Dawarich pedig megtartja a saját felvételét.', + 'journey.recording.keptToast': 'Útvonal elmentve az utazásban', + 'journey.recording.replacedToast': 'Útvonal lecserélve', + 'journey.recording.removedToast': 'Útvonal eltávolítva az utazásból', + 'journey.recording.error.recording_exists': 'Ez az utazás már tárol egy másik felvételt arról a napról.', + 'journey.recording.error.recording_changed': 'A felvétel az előnézet óta megváltozott.', + 'journey.recording.error.recording_empty': 'A Dawarich azon a napon nem rögzített semmit.', + 'journey.recording.mode.walking': 'Gyalog', + 'journey.recording.mode.running': 'Futás', + 'journey.recording.mode.cycling': 'Kerékpár', + 'journey.recording.mode.driving': 'Autó', + 'journey.recording.mode.motorcycle': 'Motor', + 'journey.recording.mode.bus': 'Busz', + 'journey.recording.mode.train': 'Vonat', + 'journey.recording.mode.flying': 'Repülés', + 'journey.recording.mode.boat': 'Hajó', + 'journey.recording.mode.stationary': 'Egy helyben', + 'journey.recording.mode.unknown': 'Ismeretlen mód', }; export default journey; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index eca375ea2..8753fdb94 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -692,5 +692,65 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Cuaca', 'journey.entry.suggestion': 'Saran', 'journey.editor.addDetails': '+ Detail', + 'journey.recording.mapLine': 'Direkam pada {date}', + 'journey.recording.keptBy': 'Rute rekaman disimpan: {distance}, diambil oleh {name} pada {date}.', + 'journey.recording.keptOn': 'Rute rekaman disimpan: {distance}, diambil pada {date}.', + 'journey.recording.keptRunning': 'Hari itu masih berlangsung saat itu.', + 'journey.recording.takeOver': 'Ambil rekaman', + 'journey.recording.takeOverDay': 'Rekaman {date}', + 'journey.recording.sub': 'Hanya hari ini, dari Dawarich kamu. Tidak ada yang disimpan sebelum kamu mengonfirmasi.', + 'journey.recording.zone': 'Zona waktu hari ini', + 'journey.recording.zoneHint': + 'Hari berjalan dari tengah malam ke tengah malam di zona ini. Pilih zona tempat kamu bepergian.', + 'journey.recording.zoneSource': 'Dari Dawarich', + 'journey.recording.zoneDevice': 'Perangkat ini', + 'journey.recording.scope': 'Rekaman', + 'journey.recording.loading': 'Membaca hari dari Dawarich…', + 'journey.recording.keptStays': 'Rute yang disimpan perjalanan ini tetap seperti semula.', + 'journey.recording.retry': 'Coba lagi', + 'journey.recording.empty': 'Dawarich tidak merekam apa pun pada hari ini di zona waktu ini.', + 'journey.recording.span': '{from} sampai {to}', + 'journey.recording.pieces': '{count} bagian', + 'journey.recording.gaps': '{count} jeda', + 'journey.recording.gapsHint': + 'Di tempat rekaman berhenti, garis juga berhenti. Tidak ada jalan yang digambar jika tidak direkam.', + 'journey.recording.sourceHint': + 'Jejak adalah rute yang sudah dibagi Dawarich menurut moda. Titik adalah posisi mentahnya, dikurangi menjadi 600 per hari.', + 'journey.recording.sourceTracks': 'Jejak', + 'journey.recording.sourcePoints': 'Titik', + 'journey.recording.changedSincePreview': + 'Rekaman berubah di Dawarich saat kamu melihatnya. Periksa sekali lagi, lalu simpan.', + 'journey.recording.truncated': 'Dawarich hanya mengirim sebagian hari ini, rutenya tidak lengkap.', + 'journey.recording.provisional': + 'Hari ini belum selesai di zona waktu ini. Nanti kamu bisa mengganti rute dengan hari penuh.', + 'journey.recording.same': 'Perjalanan ini sudah menyimpan rekaman yang persis sama.', + 'journey.recording.differs': + 'Perjalanan ini menyimpan versi lain dari {date} ({distance}). Menggantinya tidak mengubah entri atau teks.', + 'journey.recording.audience': + '{count} orang lain di perjalanan ini akan melihat rute di petanya. Tautan bersama tidak menampilkannya.', + 'journey.recording.replace': 'Ganti', + 'journey.recording.alreadyKept': 'Sudah disimpan', + 'journey.recording.keep': 'Simpan rute', + 'journey.recording.remove': 'Hapus rute', + 'journey.recording.removeTitle': 'Hapus rute yang disimpan?', + 'journey.recording.removeMessage': + 'Rute hari ini hilang dari peta perjalanan. Entri dan teks tetap ada, dan Dawarich menyimpan rekamannya sendiri.', + 'journey.recording.keptToast': 'Rute disimpan di perjalanan', + 'journey.recording.replacedToast': 'Rute diganti', + 'journey.recording.removedToast': 'Rute dihapus dari perjalanan', + 'journey.recording.error.recording_exists': 'Perjalanan ini sudah menyimpan rekaman lain untuk hari itu.', + 'journey.recording.error.recording_changed': 'Rekaman berubah sejak pratinjau.', + 'journey.recording.error.recording_empty': 'Dawarich tidak merekam apa pun pada hari itu.', + 'journey.recording.mode.walking': 'Jalan kaki', + 'journey.recording.mode.running': 'Lari', + 'journey.recording.mode.cycling': 'Bersepeda', + 'journey.recording.mode.driving': 'Mobil', + 'journey.recording.mode.motorcycle': 'Sepeda motor', + 'journey.recording.mode.bus': 'Bus', // same-as-en + 'journey.recording.mode.train': 'Kereta', + 'journey.recording.mode.flying': 'Penerbangan', + 'journey.recording.mode.boat': 'Perahu', + 'journey.recording.mode.stationary': 'Diam', + 'journey.recording.mode.unknown': 'Moda tidak dikenal', }; export default journey; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index ccbbef7a8..fefd9c6ac 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -705,5 +705,69 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Meteo', 'journey.entry.suggestion': 'Suggerimento', 'journey.editor.addDetails': '+ Dettagli', + 'journey.recording.mapLine': 'Registrato il {date}', + 'journey.recording.keptBy': 'Percorso registrato salvato: {distance}, ripreso da {name} il {date}.', + 'journey.recording.keptOn': 'Percorso registrato salvato: {distance}, ripreso il {date}.', + 'journey.recording.keptRunning': 'La giornata non era ancora finita.', + 'journey.recording.takeOver': 'Riprendi la registrazione', + 'journey.recording.takeOverDay': 'Registrazione del {date}', + 'journey.recording.sub': 'Solo questo giorno, dal tuo Dawarich. Non si salva nulla finché non confermi.', + 'journey.recording.zone': 'Fuso orario del giorno', + 'journey.recording.zoneHint': + 'Il giorno va da mezzanotte a mezzanotte in questo fuso. Scegli quello in cui viaggiavi.', + 'journey.recording.zoneSource': 'Da Dawarich', + 'journey.recording.zoneDevice': 'Questo dispositivo', + 'journey.recording.scope': 'Registrazione', + 'journey.recording.loading': 'Lettura del giorno da Dawarich…', + 'journey.recording.keptStays': 'Il percorso salvato in questo viaggio resta com’è.', + 'journey.recording.retry': 'Riprova', + 'journey.recording.empty': 'Dawarich non ha registrato nulla quel giorno in questo fuso orario.', + 'journey.recording.span': 'dalle {from} alle {to}', + 'journey.recording.pieces': '{count} tratti', + 'journey.recording.pieces.one': '{count} tratto', + 'journey.recording.gaps': '{count} interruzioni', + 'journey.recording.gaps.one': '{count} interruzione', + 'journey.recording.gapsHint': + 'Dove la registrazione si interrompe, si interrompe anche la linea. Non viene disegnato nessun tratto non registrato.', + 'journey.recording.sourceHint': + 'Le tracce sono percorsi che Dawarich ha già diviso per mezzo. I punti sono le sue posizioni grezze, ridotte a 600 al giorno.', + 'journey.recording.sourceTracks': 'Tracce', + 'journey.recording.sourcePoints': 'Punti', + 'journey.recording.changedSincePreview': + 'La registrazione è cambiata in Dawarich mentre la guardavi. Controllala ancora una volta, poi salvala.', + 'journey.recording.truncated': 'Dawarich ha inviato solo una parte di questo giorno, il percorso è incompleto.', + 'journey.recording.provisional': + 'Questo giorno non è ancora finito in questo fuso. Più tardi puoi sostituire il percorso con la giornata intera.', + 'journey.recording.same': 'Questo viaggio salva già esattamente questa registrazione.', + 'journey.recording.differs': + 'Questo viaggio salva un’altra versione del {date} ({distance}). Sostituirla non cambia voci né testi.', + 'journey.recording.audience': + 'Altre {count} persone di questo viaggio vedranno il percorso sulla sua mappa. I link condivisi non lo mostrano.', + 'journey.recording.audience.one': + 'Altre {count} persona di questo viaggio vedrà il percorso sulla sua mappa. I link condivisi non lo mostrano.', + 'journey.recording.replace': 'Sostituisci', + 'journey.recording.alreadyKept': 'Già salvato', + 'journey.recording.keep': 'Salva percorso', + 'journey.recording.remove': 'Rimuovi percorso', + 'journey.recording.removeTitle': 'Rimuovere il percorso salvato?', + 'journey.recording.removeMessage': + 'Il percorso di questo giorno sparisce dalla mappa del viaggio. Voci e testi restano, e Dawarich conserva la propria registrazione.', + 'journey.recording.keptToast': 'Percorso salvato nel viaggio', + 'journey.recording.replacedToast': 'Percorso sostituito', + 'journey.recording.removedToast': 'Percorso rimosso dal viaggio', + 'journey.recording.error.recording_exists': 'Questo viaggio salva già un’altra registrazione di quel giorno.', + 'journey.recording.error.recording_changed': 'La registrazione è cambiata dall’anteprima.', + 'journey.recording.error.recording_empty': 'Dawarich non ha registrato nulla quel giorno.', + 'journey.recording.mode.walking': 'A piedi', + 'journey.recording.mode.running': 'Corsa', + 'journey.recording.mode.cycling': 'Bicicletta', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Moto', + 'journey.recording.mode.bus': 'Autobus', + 'journey.recording.mode.train': 'Treno', + 'journey.recording.mode.flying': 'Volo', + 'journey.recording.mode.boat': 'Barca', + 'journey.recording.mode.stationary': 'Fermo', + 'journey.recording.mode.unknown': 'Mezzo sconosciuto', }; export default journey; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 3c8403be4..9b94ffd20 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -688,5 +688,63 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': '天気', 'journey.entry.suggestion': '提案', 'journey.editor.addDetails': '+詳細', + 'journey.recording.mapLine': '{date}の記録', + 'journey.recording.keptBy': '記録したルートを保存済み: {distance}、{name}が{date}に取り込み。', + 'journey.recording.keptOn': '記録したルートを保存済み: {distance}、{date}に取り込み。', + 'journey.recording.keptRunning': 'その時点ではまだ一日が終わっていませんでした。', + 'journey.recording.takeOver': '記録を取り込む', + 'journey.recording.takeOverDay': '{date}の記録', + 'journey.recording.sub': 'この日だけを、あなたのDawarichから。確定するまで何も保存されません。', + 'journey.recording.zone': 'この日のタイムゾーン', + 'journey.recording.zoneHint': + 'このゾーンの午前0時から次の午前0時までが一日になります。旅行中にいたゾーンを選んでください。', + 'journey.recording.zoneSource': 'Dawarichの設定', + 'journey.recording.zoneDevice': 'この端末', + 'journey.recording.scope': '記録', + 'journey.recording.loading': 'Dawarichからこの日を読み込み中…', + 'journey.recording.keptStays': 'この旅に保存されたルートはそのまま残ります。', + 'journey.recording.retry': '再試行', + 'journey.recording.empty': 'Dawarichはこのタイムゾーンのこの日に何も記録していません。', + 'journey.recording.span': '{from}〜{to}', + 'journey.recording.pieces': '{count}区間', + 'journey.recording.gaps': '途切れ{count}か所', + 'journey.recording.gapsHint': '記録が途切れた所で線も途切れます。記録されていない道は描かれません。', + 'journey.recording.sourceHint': + 'トラックはDawarichが移動手段ごとに分けたルートです。ポイントは生の位置情報で、1日600点に間引かれます。', + 'journey.recording.sourceTracks': 'トラック', + 'journey.recording.sourcePoints': 'ポイント', + 'journey.recording.changedSincePreview': + '確認している間にDawarichの記録が変わりました。もう一度確認してから保存してください。', + 'journey.recording.truncated': 'Dawarichはこの日の一部しか送ってこなかったため、ルートは不完全です。', + 'journey.recording.provisional': + 'このタイムゾーンではこの日はまだ終わっていません。後で一日分のルートに置き換えられます。', + 'journey.recording.same': 'この旅はすでにまったく同じ記録を保存しています。', + 'journey.recording.differs': + 'この旅には{date}の別の版({distance})が保存されています。置き換えてもエントリーや文章は変わりません。', + 'journey.recording.audience': 'この旅のほかの{count}人がマップでルートを見られます。共有リンクには表示されません。', + 'journey.recording.replace': '置き換える', + 'journey.recording.alreadyKept': '保存済み', + 'journey.recording.keep': 'ルートを保存', + 'journey.recording.remove': 'ルートを削除', + 'journey.recording.removeTitle': '保存したルートを削除しますか?', + 'journey.recording.removeMessage': + 'この日のルートが旅のマップから消えます。エントリーと文章は残り、Dawarichの記録もそのままです。', + 'journey.recording.keptToast': 'ルートを旅に保存しました', + 'journey.recording.replacedToast': 'ルートを置き換えました', + 'journey.recording.removedToast': 'ルートを旅から削除しました', + 'journey.recording.error.recording_exists': 'この旅にはその日の別の記録がすでに保存されています。', + 'journey.recording.error.recording_changed': 'プレビュー以降に記録が変わりました。', + 'journey.recording.error.recording_empty': 'Dawarichはその日に何も記録していません。', + 'journey.recording.mode.walking': '徒歩', + 'journey.recording.mode.running': 'ランニング', + 'journey.recording.mode.cycling': '自転車', + 'journey.recording.mode.driving': '車', + 'journey.recording.mode.motorcycle': 'バイク', + 'journey.recording.mode.bus': 'バス', + 'journey.recording.mode.train': '電車', + 'journey.recording.mode.flying': '飛行機', + 'journey.recording.mode.boat': '船', + 'journey.recording.mode.stationary': '停止', + 'journey.recording.mode.unknown': '不明な手段', }; export default journey; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index 1f60a59fd..e769447fd 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -686,5 +686,62 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': '날씨', 'journey.entry.suggestion': '제안', 'journey.editor.addDetails': '+ 세부 정보', + 'journey.recording.mapLine': '{date} 기록', + 'journey.recording.keptBy': '기록된 경로 저장됨: {distance}, {name}님이 {date}에 가져옴.', + 'journey.recording.keptOn': '기록된 경로 저장됨: {distance}, {date}에 가져옴.', + 'journey.recording.keptRunning': '그때는 아직 하루가 끝나지 않았습니다.', + 'journey.recording.takeOver': '기록 가져오기', + 'journey.recording.takeOverDay': '{date} 기록', + 'journey.recording.sub': '이 날만, 내 Dawarich에서. 확인하기 전에는 아무것도 저장되지 않습니다.', + 'journey.recording.zone': '이 날의 시간대', + 'journey.recording.zoneHint': '이 시간대에서 자정부터 자정까지가 하루입니다. 여행 중이던 시간대를 고르세요.', + 'journey.recording.zoneSource': 'Dawarich 기준', + 'journey.recording.zoneDevice': '이 기기', + 'journey.recording.scope': '기록', + 'journey.recording.loading': 'Dawarich에서 이 날을 읽는 중…', + 'journey.recording.keptStays': '이 여행에 저장된 경로는 그대로 유지됩니다.', + 'journey.recording.retry': '다시 시도', + 'journey.recording.empty': 'Dawarich가 이 시간대의 이 날에 아무것도 기록하지 않았습니다.', + 'journey.recording.span': '{from}부터 {to}까지', + 'journey.recording.pieces': '구간 {count}개', + 'journey.recording.gaps': '끊김 {count}곳', + 'journey.recording.gapsHint': '기록이 끊긴 곳에서는 선도 끊깁니다. 기록되지 않은 길은 그리지 않습니다.', + 'journey.recording.sourceHint': + '트랙은 Dawarich가 이동 수단별로 나눈 경로입니다. 포인트는 원시 위치이며 하루 600개로 줄입니다.', + 'journey.recording.sourceTracks': '트랙', + 'journey.recording.sourcePoints': '포인트', + 'journey.recording.changedSincePreview': '보는 동안 Dawarich의 기록이 바뀌었습니다. 한 번 더 확인한 뒤 저장하세요.', + 'journey.recording.truncated': 'Dawarich가 이 날의 일부만 보냈기 때문에 경로가 불완전합니다.', + 'journey.recording.provisional': + '이 시간대에서는 이 날이 아직 끝나지 않았습니다. 나중에 하루 전체 경로로 바꿀 수 있습니다.', + 'journey.recording.same': '이 여행에는 이미 똑같은 기록이 저장되어 있습니다.', + 'journey.recording.differs': + '이 여행에는 {date}의 다른 버전({distance})이 저장되어 있습니다. 바꿔도 항목이나 글은 바뀌지 않습니다.', + 'journey.recording.audience': + '이 여행의 다른 {count}명이 지도에서 경로를 보게 됩니다. 공유 링크에는 표시되지 않습니다.', + 'journey.recording.replace': '바꾸기', + 'journey.recording.alreadyKept': '이미 저장됨', + 'journey.recording.keep': '경로 저장', + 'journey.recording.remove': '경로 삭제', + 'journey.recording.removeTitle': '저장된 경로를 삭제할까요?', + 'journey.recording.removeMessage': + '이 날의 경로가 여행 지도에서 사라집니다. 항목과 글은 남고, Dawarich는 자체 기록을 유지합니다.', + 'journey.recording.keptToast': '경로를 여행에 저장했습니다', + 'journey.recording.replacedToast': '경로를 바꿨습니다', + 'journey.recording.removedToast': '경로를 여행에서 삭제했습니다', + 'journey.recording.error.recording_exists': '이 여행에는 그날의 다른 기록이 이미 저장되어 있습니다.', + 'journey.recording.error.recording_changed': '미리보기 이후 기록이 바뀌었습니다.', + 'journey.recording.error.recording_empty': 'Dawarich가 그날 아무것도 기록하지 않았습니다.', + 'journey.recording.mode.walking': '도보', + 'journey.recording.mode.running': '달리기', + 'journey.recording.mode.cycling': '자전거', + 'journey.recording.mode.driving': '자동차', + 'journey.recording.mode.motorcycle': '오토바이', + 'journey.recording.mode.bus': '버스', + 'journey.recording.mode.train': '기차', + 'journey.recording.mode.flying': '비행', + 'journey.recording.mode.boat': '배', + 'journey.recording.mode.stationary': '정지', + 'journey.recording.mode.unknown': '알 수 없는 수단', }; export default journey; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 8e2b8da48..52e336247 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -707,5 +707,69 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Weer', 'journey.entry.suggestion': 'Suggestie', 'journey.editor.addDetails': '+ Details', + 'journey.recording.mapLine': 'Opgenomen op {date}', + 'journey.recording.keptBy': 'Opgenomen route bewaard: {distance}, overgenomen door {name} op {date}.', + 'journey.recording.keptOn': 'Opgenomen route bewaard: {distance}, overgenomen op {date}.', + 'journey.recording.keptRunning': 'De dag was toen nog niet voorbij.', + 'journey.recording.takeOver': 'Opname overnemen', + 'journey.recording.takeOverDay': 'Opname van {date}', + 'journey.recording.sub': 'Alleen deze dag, uit je Dawarich. Er wordt niets bewaard tot je bevestigt.', + 'journey.recording.zone': 'Tijdzone van de dag', + 'journey.recording.zoneHint': + 'De dag loopt in deze zone van middernacht tot middernacht. Kies de zone waarin je reisde.', + 'journey.recording.zoneSource': 'Uit Dawarich', + 'journey.recording.zoneDevice': 'Dit apparaat', + 'journey.recording.scope': 'Opname', + 'journey.recording.loading': 'Dag wordt uit Dawarich gelezen…', + 'journey.recording.keptStays': 'De route die deze reis bewaart, blijft zoals hij is.', + 'journey.recording.retry': 'Opnieuw proberen', + 'journey.recording.empty': 'Dawarich heeft op deze dag in deze tijdzone niets opgenomen.', + 'journey.recording.span': '{from} tot {to}', + 'journey.recording.pieces': '{count} stukken', + 'journey.recording.pieces.one': '{count} stuk', + 'journey.recording.gaps': '{count} onderbrekingen', + 'journey.recording.gaps.one': '{count} onderbreking', + 'journey.recording.gapsHint': + 'Waar de opname stopt, stopt ook de lijn. Er wordt geen weg getekend die niet is opgenomen.', + 'journey.recording.sourceHint': + 'Routes zijn trajecten die Dawarich al per vervoerswijze heeft opgedeeld. Punten zijn de ruwe posities, uitgedund tot 600 per dag.', + 'journey.recording.sourceTracks': 'Routes', + 'journey.recording.sourcePoints': 'Punten', + 'journey.recording.changedSincePreview': + 'De opname is in Dawarich veranderd terwijl je keek. Controleer hem nog eens en bewaar hem dan.', + 'journey.recording.truncated': 'Dawarich stuurde maar een deel van deze dag, de route is onvolledig.', + 'journey.recording.provisional': + 'Deze dag is in deze tijdzone nog niet voorbij. Je kunt de route later vervangen door de hele dag.', + 'journey.recording.same': 'Deze reis bewaart precies deze opname al.', + 'journey.recording.differs': + 'Deze reis bewaart een andere versie van {date} ({distance}). Vervangen verandert geen items of teksten.', + 'journey.recording.audience': + '{count} andere personen in deze reis zien de route op de kaart. Gedeelde links tonen hem niet.', + 'journey.recording.audience.one': + '{count} andere persoon in deze reis ziet de route op de kaart. Gedeelde links tonen hem niet.', + 'journey.recording.replace': 'Vervangen', + 'journey.recording.alreadyKept': 'Al bewaard', + 'journey.recording.keep': 'Route bewaren', + 'journey.recording.remove': 'Route verwijderen', + 'journey.recording.removeTitle': 'Bewaarde route verwijderen?', + 'journey.recording.removeMessage': + 'De route van deze dag verdwijnt van de reiskaart. Items en teksten blijven, en Dawarich houdt zijn eigen opname.', + 'journey.recording.keptToast': 'Route in de reis bewaard', + 'journey.recording.replacedToast': 'Route vervangen', + 'journey.recording.removedToast': 'Route uit de reis verwijderd', + 'journey.recording.error.recording_exists': 'Deze reis bewaart al een andere opname van die dag.', + 'journey.recording.error.recording_changed': 'De opname is sinds het voorbeeld veranderd.', + 'journey.recording.error.recording_empty': 'Dawarich heeft op die dag niets opgenomen.', + 'journey.recording.mode.walking': 'Te voet', + 'journey.recording.mode.running': 'Hardlopen', + 'journey.recording.mode.cycling': 'Fietsen', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Motor', + 'journey.recording.mode.bus': 'Bus', // same-as-en + 'journey.recording.mode.train': 'Trein', + 'journey.recording.mode.flying': 'Vlucht', + 'journey.recording.mode.boat': 'Boot', + 'journey.recording.mode.stationary': 'Stilstand', + 'journey.recording.mode.unknown': 'Onbekende vervoerswijze', }; export default journey; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index f81602e52..a41eb51fa 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -728,5 +728,76 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Pogoda', 'journey.entry.suggestion': 'Propozycja', 'journey.editor.addDetails': '+ Szczegóły', + 'journey.recording.mapLine': 'Zarejestrowano {date}', + 'journey.recording.keptBy': 'Zarejestrowana trasa zapisana: {distance}, przejęta przez {name} dnia {date}.', + 'journey.recording.keptOn': 'Zarejestrowana trasa zapisana: {distance}, przejęta dnia {date}.', + 'journey.recording.keptRunning': 'Dzień jeszcze wtedy trwał.', + 'journey.recording.takeOver': 'Przejmij nagranie', + 'journey.recording.takeOverDay': 'Nagranie z {date}', + 'journey.recording.sub': 'Tylko ten dzień, z twojego Dawarich. Nic nie zostanie zapisane, dopóki nie potwierdzisz.', + 'journey.recording.zone': 'Strefa czasowa dnia', + 'journey.recording.zoneHint': 'Dzień trwa w tej strefie od północy do północy. Wybierz tę, w której podróżowałeś.', + 'journey.recording.zoneSource': 'Z Dawarich', + 'journey.recording.zoneDevice': 'To urządzenie', + 'journey.recording.scope': 'Nagranie', + 'journey.recording.loading': 'Wczytywanie dnia z Dawarich…', + 'journey.recording.keptStays': 'Trasa zapisana w tej podróży pozostaje bez zmian.', + 'journey.recording.retry': 'Spróbuj ponownie', + 'journey.recording.empty': 'Dawarich nic nie zarejestrował tego dnia w tej strefie czasowej.', + 'journey.recording.span': 'od {from} do {to}', + 'journey.recording.pieces': '{count} odcinka', + 'journey.recording.pieces.one': '{count} odcinek', + 'journey.recording.pieces.few': '{count} odcinki', + 'journey.recording.pieces.many': '{count} odcinków', + 'journey.recording.gaps': '{count} przerwy', + 'journey.recording.gaps.one': '{count} przerwa', + 'journey.recording.gaps.few': '{count} przerwy', + 'journey.recording.gaps.many': '{count} przerw', + 'journey.recording.gapsHint': + 'Tam, gdzie nagranie się urywa, urywa się też linia. Nie jest rysowana żadna droga, której nie nagrano.', + 'journey.recording.sourceHint': + 'Ślady to trasy, które Dawarich już podzielił według środka transportu. Punkty to surowe pozycje, przerzedzone do 600 dziennie.', + 'journey.recording.sourceTracks': 'Ślady', + 'journey.recording.sourcePoints': 'Punkty', + 'journey.recording.changedSincePreview': + 'Nagranie zmieniło się w Dawarich, gdy je oglądałeś. Sprawdź je jeszcze raz, a potem zapisz.', + 'journey.recording.truncated': 'Dawarich wysłał tylko część tego dnia, trasa jest niepełna.', + 'journey.recording.provisional': + 'Ten dzień w tej strefie jeszcze się nie skończył. Później możesz zastąpić trasę całym dniem.', + 'journey.recording.same': 'Ta podróż już przechowuje dokładnie to nagranie.', + 'journey.recording.differs': + 'Ta podróż przechowuje inną wersję z {date} ({distance}). Zastąpienie nie zmienia żadnych wpisów ani tekstów.', + 'journey.recording.audience': + '{count} innej osoby z tej podróży zobaczy trasę na mapie. Udostępnione linki jej nie pokazują.', + 'journey.recording.audience.one': + '{count} inna osoba z tej podróży zobaczy trasę na mapie. Udostępnione linki jej nie pokazują.', + 'journey.recording.audience.few': + '{count} inne osoby z tej podróży zobaczą trasę na mapie. Udostępnione linki jej nie pokazują.', + 'journey.recording.audience.many': + '{count} innych osób z tej podróży zobaczy trasę na mapie. Udostępnione linki jej nie pokazują.', + 'journey.recording.replace': 'Zastąp', + 'journey.recording.alreadyKept': 'Już zapisana', + 'journey.recording.keep': 'Zapisz trasę', + 'journey.recording.remove': 'Usuń trasę', + 'journey.recording.removeTitle': 'Usunąć zapisaną trasę?', + 'journey.recording.removeMessage': + 'Trasa tego dnia znika z mapy podróży. Wpisy i teksty zostają, a Dawarich zachowuje własne nagranie.', + 'journey.recording.keptToast': 'Trasa zapisana w podróży', + 'journey.recording.replacedToast': 'Trasa zastąpiona', + 'journey.recording.removedToast': 'Trasa usunięta z podróży', + 'journey.recording.error.recording_exists': 'Ta podróż przechowuje już inne nagranie z tego dnia.', + 'journey.recording.error.recording_changed': 'Nagranie zmieniło się od podglądu.', + 'journey.recording.error.recording_empty': 'Dawarich nic nie zarejestrował tego dnia.', + 'journey.recording.mode.walking': 'Pieszo', + 'journey.recording.mode.running': 'Bieg', + 'journey.recording.mode.cycling': 'Rower', + 'journey.recording.mode.driving': 'Samochód', + 'journey.recording.mode.motorcycle': 'Motocykl', + 'journey.recording.mode.bus': 'Autobus', + 'journey.recording.mode.train': 'Pociąg', + 'journey.recording.mode.flying': 'Lot', + 'journey.recording.mode.boat': 'Łódź', + 'journey.recording.mode.stationary': 'W miejscu', + 'journey.recording.mode.unknown': 'Nieznany środek', }; export default journey; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 9277d3d3c..36a362da4 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -729,5 +729,75 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Погода', 'journey.entry.suggestion': 'Предложение', 'journey.editor.addDetails': '+ Подробности', + 'journey.recording.mapLine': 'Записано {date}', + 'journey.recording.keptBy': 'Записанный маршрут сохранён: {distance}, перенёс(ла) {name} {date}.', + 'journey.recording.keptOn': 'Записанный маршрут сохранён: {distance}, перенесён {date}.', + 'journey.recording.keptRunning': 'Тогда день ещё не закончился.', + 'journey.recording.takeOver': 'Перенести запись', + 'journey.recording.takeOverDay': 'Запись за {date}', + 'journey.recording.sub': 'Только этот день, из вашего Dawarich. Ничего не сохраняется, пока вы не подтвердите.', + 'journey.recording.zone': 'Часовой пояс дня', + 'journey.recording.zoneHint': + 'День длится в этом поясе от полуночи до полуночи. Выберите тот, в котором вы путешествовали.', + 'journey.recording.zoneSource': 'Из Dawarich', + 'journey.recording.zoneDevice': 'Это устройство', + 'journey.recording.scope': 'Запись', + 'journey.recording.loading': 'Читаем день из Dawarich…', + 'journey.recording.keptStays': 'Маршрут, сохранённый в этом путешествии, остаётся без изменений.', + 'journey.recording.retry': 'Повторить', + 'journey.recording.empty': 'Dawarich ничего не записал в этот день в этом часовом поясе.', + 'journey.recording.span': 'с {from} до {to}', + 'journey.recording.pieces': '{count} участка', + 'journey.recording.pieces.one': '{count} участок', + 'journey.recording.pieces.few': '{count} участка', + 'journey.recording.pieces.many': '{count} участков', + 'journey.recording.gaps': '{count} пропуска', + 'journey.recording.gaps.one': '{count} пропуск', + 'journey.recording.gaps.few': '{count} пропуска', + 'journey.recording.gaps.many': '{count} пропусков', + 'journey.recording.gapsHint': 'Где запись обрывается, обрывается и линия. Не рисуется путь, который не был записан.', + 'journey.recording.sourceHint': + 'Треки: маршруты, которые Dawarich уже разделил по способу передвижения. Точки: его сырые позиции, прореженные до 600 в день.', + 'journey.recording.sourceTracks': 'Треки', + 'journey.recording.sourcePoints': 'Точки', + 'journey.recording.changedSincePreview': + 'Запись изменилась в Dawarich, пока вы её смотрели. Проверьте её ещё раз, затем сохраните.', + 'journey.recording.truncated': 'Dawarich прислал только часть этого дня, маршрут неполный.', + 'journey.recording.provisional': 'Этот день в этом поясе ещё не закончился. Позже маршрут можно заменить целым днём.', + 'journey.recording.same': 'Это путешествие уже хранит именно эту запись.', + 'journey.recording.differs': + 'Это путешествие хранит другую версию от {date} ({distance}). Замена не меняет записи и тексты.', + 'journey.recording.audience': + 'Ещё {count} участника этого путешествия увидят маршрут на карте. Общие ссылки его не показывают.', + 'journey.recording.audience.one': + 'Ещё {count} участник этого путешествия увидит маршрут на карте. Общие ссылки его не показывают.', + 'journey.recording.audience.few': + 'Ещё {count} участника этого путешествия увидят маршрут на карте. Общие ссылки его не показывают.', + 'journey.recording.audience.many': + 'Ещё {count} участников этого путешествия увидят маршрут на карте. Общие ссылки его не показывают.', + 'journey.recording.replace': 'Заменить', + 'journey.recording.alreadyKept': 'Уже сохранено', + 'journey.recording.keep': 'Сохранить маршрут', + 'journey.recording.remove': 'Убрать маршрут', + 'journey.recording.removeTitle': 'Убрать сохранённый маршрут?', + 'journey.recording.removeMessage': + 'Маршрут этого дня исчезнет с карты путешествия. Записи и тексты останутся, а Dawarich сохранит свою запись.', + 'journey.recording.keptToast': 'Маршрут сохранён в путешествии', + 'journey.recording.replacedToast': 'Маршрут заменён', + 'journey.recording.removedToast': 'Маршрут убран из путешествия', + 'journey.recording.error.recording_exists': 'Это путешествие уже хранит другую запись этого дня.', + 'journey.recording.error.recording_changed': 'Запись изменилась после предпросмотра.', + 'journey.recording.error.recording_empty': 'Dawarich ничего не записал в этот день.', + 'journey.recording.mode.walking': 'Пешком', + 'journey.recording.mode.running': 'Бег', + 'journey.recording.mode.cycling': 'Велосипед', + 'journey.recording.mode.driving': 'Автомобиль', + 'journey.recording.mode.motorcycle': 'Мотоцикл', + 'journey.recording.mode.bus': 'Автобус', + 'journey.recording.mode.train': 'Поезд', + 'journey.recording.mode.flying': 'Перелёт', + 'journey.recording.mode.boat': 'Лодка', + 'journey.recording.mode.stationary': 'На месте', + 'journey.recording.mode.unknown': 'Неизвестный способ', }; export default journey; diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts index c808bb3da..922c26bc8 100644 --- a/shared/src/i18n/sk/journey.ts +++ b/shared/src/i18n/sk/journey.ts @@ -717,5 +717,70 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Počasie', 'journey.entry.suggestion': 'Návrh', 'journey.editor.addDetails': '+ Podrobnosti', + 'journey.recording.mapLine': 'Zaznamenané {date}', + 'journey.recording.keptBy': 'Zaznamenaná trasa uložená: {distance}, prevzal(a) {name} dňa {date}.', + 'journey.recording.keptOn': 'Zaznamenaná trasa uložená: {distance}, prevzaté dňa {date}.', + 'journey.recording.keptRunning': 'Deň vtedy ešte neskončil.', + 'journey.recording.takeOver': 'Prevziať záznam', + 'journey.recording.takeOverDay': 'Záznam z {date}', + 'journey.recording.sub': 'Len tento deň, z tvojho Dawarichu. Nič sa neuloží, kým nepotvrdíš.', + 'journey.recording.zone': 'Časové pásmo dňa', + 'journey.recording.zoneHint': 'Deň trvá v tomto pásme od polnoci do polnoci. Vyber to, v ktorom si cestoval(a).', + 'journey.recording.zoneSource': 'Z Dawarichu', + 'journey.recording.zoneDevice': 'Toto zariadenie', + 'journey.recording.scope': 'Záznam', + 'journey.recording.loading': 'Načítava sa deň z Dawarichu…', + 'journey.recording.keptStays': 'Trasa, ktorú táto cesta uchováva, zostáva bez zmeny.', + 'journey.recording.retry': 'Skúsiť znova', + 'journey.recording.empty': 'Dawarich v tento deň v tomto časovom pásme nič nezaznamenal.', + 'journey.recording.span': '{from} až {to}', + 'journey.recording.pieces': '{count} úsekov', + 'journey.recording.pieces.one': '{count} úsek', + 'journey.recording.pieces.few': '{count} úseky', + 'journey.recording.gaps': '{count} medzier', + 'journey.recording.gaps.one': '{count} medzera', + 'journey.recording.gaps.few': '{count} medzery', + 'journey.recording.gapsHint': 'Kde záznam končí, končí aj čiara. Nekreslí sa žiadna cesta, ktorá nebola zaznamenaná.', + 'journey.recording.sourceHint': + 'Stopy sú trasy, ktoré Dawarich už rozdelil podľa spôsobu dopravy. Body sú jeho surové polohy, preriedené na 600 za deň.', + 'journey.recording.sourceTracks': 'Stopy', + 'journey.recording.sourcePoints': 'Body', + 'journey.recording.changedSincePreview': + 'Záznam sa v Dawarichu zmenil, kým si ho prezeral(a). Skontroluj ho ešte raz a potom ho ulož.', + 'journey.recording.truncated': 'Dawarich poslal len časť tohto dňa, trasa je neúplná.', + 'journey.recording.provisional': 'Tento deň v tomto pásme ešte neskončil. Trasu môžeš neskôr nahradiť celým dňom.', + 'journey.recording.same': 'Táto cesta už uchováva presne tento záznam.', + 'journey.recording.differs': + 'Táto cesta uchováva inú verziu z {date} ({distance}). Nahradenie nezmení žiadne záznamy ani texty.', + 'journey.recording.audience': + 'Ďalších {count} ľudí na tejto ceste uvidí trasu na mape. Zdieľané odkazy ju nezobrazujú.', + 'journey.recording.audience.one': + 'Ďalší {count} človek na tejto ceste uvidí trasu na mape. Zdieľané odkazy ju nezobrazujú.', + 'journey.recording.audience.few': + 'Ďalší {count} ľudia na tejto ceste uvidia trasu na mape. Zdieľané odkazy ju nezobrazujú.', + 'journey.recording.replace': 'Nahradiť', + 'journey.recording.alreadyKept': 'Už uložené', + 'journey.recording.keep': 'Uložiť trasu', + 'journey.recording.remove': 'Odstrániť trasu', + 'journey.recording.removeTitle': 'Odstrániť uloženú trasu?', + 'journey.recording.removeMessage': + 'Trasa tohto dňa zmizne z mapy cesty. Záznamy a texty zostanú a Dawarich si ponechá vlastný záznam.', + 'journey.recording.keptToast': 'Trasa uložená do cesty', + 'journey.recording.replacedToast': 'Trasa nahradená', + 'journey.recording.removedToast': 'Trasa odstránená z cesty', + 'journey.recording.error.recording_exists': 'Táto cesta už pre ten deň uchováva iný záznam.', + 'journey.recording.error.recording_changed': 'Záznam sa od náhľadu zmenil.', + 'journey.recording.error.recording_empty': 'Dawarich v ten deň nič nezaznamenal.', + 'journey.recording.mode.walking': 'Pešo', + 'journey.recording.mode.running': 'Beh', + 'journey.recording.mode.cycling': 'Bicykel', + 'journey.recording.mode.driving': 'Auto', + 'journey.recording.mode.motorcycle': 'Motorka', + 'journey.recording.mode.bus': 'Autobus', + 'journey.recording.mode.train': 'Vlak', + 'journey.recording.mode.flying': 'Let', + 'journey.recording.mode.boat': 'Loď', + 'journey.recording.mode.stationary': 'Na mieste', + 'journey.recording.mode.unknown': 'Neznámy spôsob', }; export default journey; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 296e35494..30e0cef54 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -703,5 +703,67 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Väder', 'journey.entry.suggestion': 'Förslag', 'journey.editor.addDetails': '+ Detaljer', + 'journey.recording.mapLine': 'Inspelad {date}', + 'journey.recording.keptBy': 'Inspelad rutt sparad: {distance}, övertagen av {name} den {date}.', + 'journey.recording.keptOn': 'Inspelad rutt sparad: {distance}, övertagen den {date}.', + 'journey.recording.keptRunning': 'Dagen pågick fortfarande då.', + 'journey.recording.takeOver': 'Ta över inspelningen', + 'journey.recording.takeOverDay': 'Inspelning från {date}', + 'journey.recording.sub': 'Bara den här dagen, från din Dawarich. Inget sparas förrän du bekräftar.', + 'journey.recording.zone': 'Dagens tidszon', + 'journey.recording.zoneHint': 'Dagen går från midnatt till midnatt i den här zonen. Välj den du reste i.', + 'journey.recording.zoneSource': 'Från Dawarich', + 'journey.recording.zoneDevice': 'Den här enheten', + 'journey.recording.scope': 'Inspelning', + 'journey.recording.loading': 'Läser dagen från Dawarich…', + 'journey.recording.keptStays': 'Rutten som den här resan sparar förblir som den är.', + 'journey.recording.retry': 'Försök igen', + 'journey.recording.empty': 'Dawarich spelade inte in något den dagen i den här tidszonen.', + 'journey.recording.span': '{from} till {to}', + 'journey.recording.pieces': '{count} delar', + 'journey.recording.pieces.one': '{count} del', + 'journey.recording.gaps': '{count} luckor', + 'journey.recording.gaps.one': '{count} lucka', + 'journey.recording.gapsHint': 'Där inspelningen slutar, slutar också linjen. Ingen väg ritas som inte spelades in.', + 'journey.recording.sourceHint': + 'Spår är rutter som Dawarich redan delat upp efter färdsätt. Punkter är dess råa positioner, glesade till 600 per dag.', + 'journey.recording.sourceTracks': 'Spår', + 'journey.recording.sourcePoints': 'Punkter', + 'journey.recording.changedSincePreview': + 'Inspelningen ändrades i Dawarich medan du tittade. Kontrollera den en gång till och spara den sedan.', + 'journey.recording.truncated': 'Dawarich skickade bara en del av dagen, rutten är ofullständig.', + 'journey.recording.provisional': + 'Dagen är inte slut än i den här tidszonen. Du kan ersätta rutten senare med hela dagen.', + 'journey.recording.same': 'Den här resan sparar redan exakt den här inspelningen.', + 'journey.recording.differs': + 'Den här resan sparar en annan version från {date} ({distance}). Att ersätta den ändrar inga inlägg eller texter.', + 'journey.recording.audience': + '{count} andra personer i den här resan ser rutten på kartan. Delade länkar visar den inte.', + 'journey.recording.audience.one': + '{count} annan person i den här resan ser rutten på kartan. Delade länkar visar den inte.', + 'journey.recording.replace': 'Ersätt', + 'journey.recording.alreadyKept': 'Redan sparad', + 'journey.recording.keep': 'Spara rutt', + 'journey.recording.remove': 'Ta bort rutt', + 'journey.recording.removeTitle': 'Ta bort den sparade rutten?', + 'journey.recording.removeMessage': + 'Dagens rutt försvinner från resans karta. Inlägg och texter finns kvar, och Dawarich behåller sin egen inspelning.', + 'journey.recording.keptToast': 'Rutten sparad i resan', + 'journey.recording.replacedToast': 'Rutten ersatt', + 'journey.recording.removedToast': 'Rutten borttagen från resan', + 'journey.recording.error.recording_exists': 'Den här resan sparar redan en annan inspelning av den dagen.', + 'journey.recording.error.recording_changed': 'Inspelningen har ändrats sedan förhandsvisningen.', + 'journey.recording.error.recording_empty': 'Dawarich spelade inte in något den dagen.', + 'journey.recording.mode.walking': 'Till fots', + 'journey.recording.mode.running': 'Löpning', + 'journey.recording.mode.cycling': 'Cykel', + 'journey.recording.mode.driving': 'Bil', + 'journey.recording.mode.motorcycle': 'Motorcykel', + 'journey.recording.mode.bus': 'Buss', + 'journey.recording.mode.train': 'Tåg', + 'journey.recording.mode.flying': 'Flyg', + 'journey.recording.mode.boat': 'Båt', + 'journey.recording.mode.stationary': 'Stillastående', + 'journey.recording.mode.unknown': 'Okänt färdsätt', }; export default journey; diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts index f3ed803d9..ce5dc9bab 100644 --- a/shared/src/i18n/th/journey.ts +++ b/shared/src/i18n/th/journey.ts @@ -682,5 +682,60 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'สภาพอากาศ', 'journey.entry.suggestion': 'คำแนะนำ', 'journey.editor.addDetails': '+ รายละเอียด', + 'journey.recording.mapLine': 'บันทึกเมื่อ {date}', + 'journey.recording.keptBy': 'บันทึกเส้นทางที่บันทึกไว้แล้ว: {distance} นำเข้าโดย {name} เมื่อ {date}', + 'journey.recording.keptOn': 'บันทึกเส้นทางที่บันทึกไว้แล้ว: {distance} นำเข้าเมื่อ {date}', + 'journey.recording.keptRunning': 'ตอนนั้นวันยังไม่จบ', + 'journey.recording.takeOver': 'นำเข้าการบันทึก', + 'journey.recording.takeOverDay': 'การบันทึกวันที่ {date}', + 'journey.recording.sub': 'เฉพาะวันนี้ จาก Dawarich ของคุณ จะไม่มีการบันทึกอะไรจนกว่าคุณจะยืนยัน', + 'journey.recording.zone': 'เขตเวลาของวัน', + 'journey.recording.zoneHint': 'วันนี้นับจากเที่ยงคืนถึงเที่ยงคืนในเขตเวลานี้ เลือกเขตเวลาที่คุณเดินทางอยู่', + 'journey.recording.zoneSource': 'ตาม Dawarich', + 'journey.recording.zoneDevice': 'อุปกรณ์นี้', + 'journey.recording.scope': 'การบันทึก', + 'journey.recording.loading': 'กำลังอ่านวันนี้จาก Dawarich…', + 'journey.recording.keptStays': 'เส้นทางที่ทริปนี้เก็บไว้ยังคงเหมือนเดิม', + 'journey.recording.retry': 'ลองอีกครั้ง', + 'journey.recording.empty': 'Dawarich ไม่ได้บันทึกอะไรในวันนี้ในเขตเวลานี้', + 'journey.recording.span': '{from} ถึง {to}', + 'journey.recording.pieces': '{count} ช่วง', + 'journey.recording.gaps': 'ขาดหาย {count} จุด', + 'journey.recording.gapsHint': 'ที่ใดการบันทึกหยุด เส้นก็หยุดด้วย จะไม่วาดเส้นทางที่ไม่ได้บันทึก', + 'journey.recording.sourceHint': + 'แทร็กคือเส้นทางที่ Dawarich แบ่งตามวิธีเดินทางแล้ว จุดคือตำแหน่งดิบ ลดเหลือ 600 จุดต่อวัน', + 'journey.recording.sourceTracks': 'แทร็ก', + 'journey.recording.sourcePoints': 'จุด', + 'journey.recording.changedSincePreview': + 'การบันทึกใน Dawarich เปลี่ยนไประหว่างที่คุณดูอยู่ ตรวจสอบอีกครั้งแล้วจึงบันทึก', + 'journey.recording.truncated': 'Dawarich ส่งมาเพียงบางส่วนของวันนี้ เส้นทางจึงไม่ครบ', + 'journey.recording.provisional': 'วันนี้ยังไม่จบในเขตเวลานี้ คุณเปลี่ยนเส้นทางเป็นทั้งวันได้ภายหลัง', + 'journey.recording.same': 'ทริปนี้เก็บการบันทึกนี้ไว้แล้วทุกประการ', + 'journey.recording.differs': 'ทริปนี้เก็บอีกเวอร์ชันจาก {date} ({distance}) การแทนที่จะไม่เปลี่ยนรายการหรือข้อความใด', + 'journey.recording.audience': 'อีก {count} คนในทริปนี้จะเห็นเส้นทางบนแผนที่ ลิงก์ที่แชร์จะไม่แสดง', + 'journey.recording.replace': 'แทนที่', + 'journey.recording.alreadyKept': 'บันทึกแล้ว', + 'journey.recording.keep': 'บันทึกเส้นทาง', + 'journey.recording.remove': 'ลบเส้นทาง', + 'journey.recording.removeTitle': 'ลบเส้นทางที่บันทึกไว้หรือไม่', + 'journey.recording.removeMessage': + 'เส้นทางของวันนี้จะหายไปจากแผนที่ทริป รายการและข้อความยังอยู่ และ Dawarich ยังเก็บการบันทึกของตัวเองไว้', + 'journey.recording.keptToast': 'บันทึกเส้นทางในทริปแล้ว', + 'journey.recording.replacedToast': 'แทนที่เส้นทางแล้ว', + 'journey.recording.removedToast': 'ลบเส้นทางออกจากทริปแล้ว', + 'journey.recording.error.recording_exists': 'ทริปนี้เก็บการบันทึกอื่นของวันนั้นไว้แล้ว', + 'journey.recording.error.recording_changed': 'การบันทึกเปลี่ยนไปตั้งแต่ดูตัวอย่าง', + 'journey.recording.error.recording_empty': 'Dawarich ไม่ได้บันทึกอะไรในวันนั้น', + 'journey.recording.mode.walking': 'เดิน', + 'journey.recording.mode.running': 'วิ่ง', + 'journey.recording.mode.cycling': 'จักรยาน', + 'journey.recording.mode.driving': 'รถยนต์', + 'journey.recording.mode.motorcycle': 'มอเตอร์ไซค์', + 'journey.recording.mode.bus': 'รถบัส', + 'journey.recording.mode.train': 'รถไฟ', + 'journey.recording.mode.flying': 'เครื่องบิน', + 'journey.recording.mode.boat': 'เรือ', + 'journey.recording.mode.stationary': 'อยู่กับที่', + 'journey.recording.mode.unknown': 'ไม่ทราบวิธี', }; export default journey; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index c01c6a32c..4c6c58852 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -703,5 +703,67 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Hava durumu', 'journey.entry.suggestion': 'Öneri', 'journey.editor.addDetails': '+ Ayrıntılar', + 'journey.recording.mapLine': '{date} tarihinde kaydedildi', + 'journey.recording.keptBy': 'Kaydedilen rota saklandı: {distance}, {name} tarafından {date} tarihinde alındı.', + 'journey.recording.keptOn': 'Kaydedilen rota saklandı: {distance}, {date} tarihinde alındı.', + 'journey.recording.keptRunning': 'Gün o sırada henüz bitmemişti.', + 'journey.recording.takeOver': 'Kaydı al', + 'journey.recording.takeOverDay': '{date} kaydı', + 'journey.recording.sub': 'Yalnızca bu gün, senin Dawarich hesabından. Onaylayana kadar hiçbir şey saklanmaz.', + 'journey.recording.zone': 'Günün saat dilimi', + 'journey.recording.zoneHint': 'Gün bu dilimde gece yarısından gece yarısına kadar sürer. Seyahat ettiğin dilimi seç.', + 'journey.recording.zoneSource': 'Dawarich’ten', + 'journey.recording.zoneDevice': 'Bu cihaz', + 'journey.recording.scope': 'Kayıt', + 'journey.recording.loading': 'Gün Dawarich’ten okunuyor…', + 'journey.recording.keptStays': 'Bu seyahatin sakladığı rota olduğu gibi kalır.', + 'journey.recording.retry': 'Tekrar dene', + 'journey.recording.empty': 'Dawarich bu gün bu saat diliminde hiçbir şey kaydetmedi.', + 'journey.recording.span': '{from} ile {to} arası', + 'journey.recording.pieces': '{count} parça', + 'journey.recording.pieces.one': '{count} parça', + 'journey.recording.gaps': '{count} boşluk', + 'journey.recording.gaps.one': '{count} boşluk', + 'journey.recording.gapsHint': 'Kaydın durduğu yerde çizgi de durur. Kaydedilmemiş hiçbir yol çizilmez.', + 'journey.recording.sourceHint': + 'İzler, Dawarich’in ulaşım türüne göre böldüğü rotalardır. Noktalar ham konumlardır, günde 600’e indirilir.', + 'journey.recording.sourceTracks': 'İzler', + 'journey.recording.sourcePoints': 'Noktalar', + 'journey.recording.changedSincePreview': + 'Sen bakarken kayıt Dawarich’te değişti. Bir kez daha kontrol et, sonra sakla.', + 'journey.recording.truncated': 'Dawarich bu günün yalnızca bir kısmını gönderdi, rota eksik.', + 'journey.recording.provisional': + 'Bu gün bu saat diliminde henüz bitmedi. Rotayı daha sonra günün tamamıyla değiştirebilirsin.', + 'journey.recording.same': 'Bu seyahat tam olarak bu kaydı zaten saklıyor.', + 'journey.recording.differs': + 'Bu seyahat {date} tarihli başka bir sürümü saklıyor ({distance}). Değiştirmek hiçbir girişi veya metni değiştirmez.', + 'journey.recording.audience': + 'Bu seyahatteki {count} kişi daha rotayı haritada görecek. Paylaşılan bağlantılar göstermez.', + 'journey.recording.audience.one': + 'Bu seyahatteki {count} kişi daha rotayı haritada görecek. Paylaşılan bağlantılar göstermez.', + 'journey.recording.replace': 'Değiştir', + 'journey.recording.alreadyKept': 'Zaten saklandı', + 'journey.recording.keep': 'Rotayı sakla', + 'journey.recording.remove': 'Rotayı kaldır', + 'journey.recording.removeTitle': 'Saklanan rota kaldırılsın mı?', + 'journey.recording.removeMessage': + 'Bu günün rotası seyahat haritasından kalkar. Girişler ve metinler kalır, Dawarich de kendi kaydını korur.', + 'journey.recording.keptToast': 'Rota seyahatte saklandı', + 'journey.recording.replacedToast': 'Rota değiştirildi', + 'journey.recording.removedToast': 'Rota seyahatten kaldırıldı', + 'journey.recording.error.recording_exists': 'Bu seyahat o gün için zaten başka bir kayıt saklıyor.', + 'journey.recording.error.recording_changed': 'Kayıt önizlemeden bu yana değişti.', + 'journey.recording.error.recording_empty': 'Dawarich o gün hiçbir şey kaydetmedi.', + 'journey.recording.mode.walking': 'Yürüyüş', + 'journey.recording.mode.running': 'Koşu', + 'journey.recording.mode.cycling': 'Bisiklet', + 'journey.recording.mode.driving': 'Araba', + 'journey.recording.mode.motorcycle': 'Motosiklet', + 'journey.recording.mode.bus': 'Otobüs', + 'journey.recording.mode.train': 'Tren', + 'journey.recording.mode.flying': 'Uçuş', + 'journey.recording.mode.boat': 'Tekne', + 'journey.recording.mode.stationary': 'Durağan', + 'journey.recording.mode.unknown': 'Bilinmeyen tür', }; export default journey; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 29f5e0ee2..99cb0cbbd 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -733,5 +733,77 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Погода', 'journey.entry.suggestion': 'Пропозиція', 'journey.editor.addDetails': '+ Деталі', + 'journey.recording.mapLine': 'Записано {date}', + 'journey.recording.keptBy': 'Записаний маршрут збережено: {distance}, переніс(ла) {name} {date}.', + 'journey.recording.keptOn': 'Записаний маршрут збережено: {distance}, перенесено {date}.', + 'journey.recording.keptRunning': 'Тоді день ще не закінчився.', + 'journey.recording.takeOver': 'Перенести запис', + 'journey.recording.takeOverDay': 'Запис за {date}', + 'journey.recording.sub': 'Лише цей день, з вашого Dawarich. Нічого не зберігається, доки ви не підтвердите.', + 'journey.recording.zone': 'Часовий пояс дня', + 'journey.recording.zoneHint': + 'День триває в цьому поясі від півночі до півночі. Оберіть той, у якому ви подорожували.', + 'journey.recording.zoneSource': 'З Dawarich', + 'journey.recording.zoneDevice': 'Цей пристрій', + 'journey.recording.scope': 'Запис', + 'journey.recording.loading': 'Читаємо день із Dawarich…', + 'journey.recording.keptStays': 'Маршрут, збережений у цій подорожі, лишається без змін.', + 'journey.recording.retry': 'Спробувати ще раз', + 'journey.recording.empty': 'Dawarich нічого не записав цього дня в цьому часовому поясі.', + 'journey.recording.span': 'з {from} до {to}', + 'journey.recording.pieces': '{count} ділянки', + 'journey.recording.pieces.one': '{count} ділянка', + 'journey.recording.pieces.few': '{count} ділянки', + 'journey.recording.pieces.many': '{count} ділянок', + 'journey.recording.gaps': '{count} пропуску', + 'journey.recording.gaps.one': '{count} пропуск', + 'journey.recording.gaps.few': '{count} пропуски', + 'journey.recording.gaps.many': '{count} пропусків', + 'journey.recording.gapsHint': + 'Де запис обривається, там обривається і лінія. Не малюється шлях, якого не було записано.', + 'journey.recording.sourceHint': + 'Треки: маршрути, які Dawarich уже розділив за способом пересування. Точки: його сирі позиції, проріджені до 600 на день.', + 'journey.recording.sourceTracks': 'Треки', + 'journey.recording.sourcePoints': 'Точки', + 'journey.recording.changedSincePreview': + 'Запис змінився в Dawarich, поки ви його переглядали. Перевірте його ще раз, а потім збережіть.', + 'journey.recording.truncated': 'Dawarich надіслав лише частину цього дня, маршрут неповний.', + 'journey.recording.provisional': + 'Цей день у цьому поясі ще не закінчився. Пізніше маршрут можна замінити цілим днем.', + 'journey.recording.same': 'Ця подорож уже зберігає саме цей запис.', + 'journey.recording.differs': + 'Ця подорож зберігає іншу версію від {date} ({distance}). Заміна не змінює записи й тексти.', + 'journey.recording.audience': + 'Ще {count} учасника цієї подорожі побачать маршрут на карті. Спільні посилання його не показують.', + 'journey.recording.audience.one': + 'Ще {count} учасник цієї подорожі побачить маршрут на карті. Спільні посилання його не показують.', + 'journey.recording.audience.few': + 'Ще {count} учасники цієї подорожі побачать маршрут на карті. Спільні посилання його не показують.', + 'journey.recording.audience.many': + 'Ще {count} учасників цієї подорожі побачать маршрут на карті. Спільні посилання його не показують.', + 'journey.recording.replace': 'Замінити', + 'journey.recording.alreadyKept': 'Уже збережено', + 'journey.recording.keep': 'Зберегти маршрут', + 'journey.recording.remove': 'Прибрати маршрут', + 'journey.recording.removeTitle': 'Прибрати збережений маршрут?', + 'journey.recording.removeMessage': + 'Маршрут цього дня зникне з карти подорожі. Записи й тексти лишаться, а Dawarich збереже власний запис.', + 'journey.recording.keptToast': 'Маршрут збережено в подорожі', + 'journey.recording.replacedToast': 'Маршрут замінено', + 'journey.recording.removedToast': 'Маршрут прибрано з подорожі', + 'journey.recording.error.recording_exists': 'Ця подорож уже зберігає інший запис цього дня.', + 'journey.recording.error.recording_changed': 'Запис змінився після попереднього перегляду.', + 'journey.recording.error.recording_empty': 'Dawarich нічого не записав цього дня.', + 'journey.recording.mode.walking': 'Пішки', + 'journey.recording.mode.running': 'Біг', + 'journey.recording.mode.cycling': 'Велосипед', + 'journey.recording.mode.driving': 'Автомобіль', + 'journey.recording.mode.motorcycle': 'Мотоцикл', + 'journey.recording.mode.bus': 'Автобус', + 'journey.recording.mode.train': 'Потяг', + 'journey.recording.mode.flying': 'Переліт', + 'journey.recording.mode.boat': 'Човен', + 'journey.recording.mode.stationary': 'На місці', + 'journey.recording.mode.unknown': 'Невідомий спосіб', }; export default journey; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index 47ca318ef..32330a730 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -690,5 +690,64 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': 'Thời tiết', 'journey.entry.suggestion': 'Gợi ý', 'journey.editor.addDetails': '+ Chi tiết', + 'journey.recording.mapLine': 'Ghi lại ngày {date}', + 'journey.recording.keptBy': 'Đã lưu tuyến đường ghi lại: {distance}, do {name} lấy vào ngày {date}.', + 'journey.recording.keptOn': 'Đã lưu tuyến đường ghi lại: {distance}, lấy vào ngày {date}.', + 'journey.recording.keptRunning': 'Khi đó ngày vẫn chưa kết thúc.', + 'journey.recording.takeOver': 'Lấy bản ghi', + 'journey.recording.takeOverDay': 'Bản ghi ngày {date}', + 'journey.recording.sub': 'Chỉ ngày này, từ Dawarich của bạn. Không có gì được lưu cho đến khi bạn xác nhận.', + 'journey.recording.zone': 'Múi giờ của ngày', + 'journey.recording.zoneHint': + 'Ngày kéo dài từ nửa đêm đến nửa đêm theo múi giờ này. Hãy chọn múi giờ nơi bạn đang đi.', + 'journey.recording.zoneSource': 'Theo Dawarich', + 'journey.recording.zoneDevice': 'Thiết bị này', + 'journey.recording.scope': 'Bản ghi', + 'journey.recording.loading': 'Đang đọc ngày từ Dawarich…', + 'journey.recording.keptStays': 'Tuyến đường chuyến đi này đang lưu vẫn giữ nguyên.', + 'journey.recording.retry': 'Thử lại', + 'journey.recording.empty': 'Dawarich không ghi lại gì vào ngày này theo múi giờ này.', + 'journey.recording.span': 'từ {from} đến {to}', + 'journey.recording.pieces': '{count} đoạn', + 'journey.recording.gaps': '{count} khoảng trống', + 'journey.recording.gapsHint': 'Chỗ bản ghi dừng thì đường kẻ cũng dừng. Không vẽ đoạn đường nào không được ghi lại.', + 'journey.recording.sourceHint': + 'Lộ trình là các tuyến Dawarich đã chia theo phương tiện. Điểm là vị trí thô, được rút gọn còn 600 mỗi ngày.', + 'journey.recording.sourceTracks': 'Lộ trình', + 'journey.recording.sourcePoints': 'Điểm', + 'journey.recording.changedSincePreview': + 'Bản ghi đã thay đổi trong Dawarich khi bạn đang xem. Hãy kiểm tra lại một lần rồi lưu.', + 'journey.recording.truncated': 'Dawarich chỉ gửi một phần của ngày này, tuyến đường chưa đầy đủ.', + 'journey.recording.provisional': + 'Ngày này chưa kết thúc theo múi giờ này. Sau này bạn có thể thay tuyến đường bằng cả ngày.', + 'journey.recording.same': 'Chuyến đi này đã lưu đúng bản ghi này rồi.', + 'journey.recording.differs': + 'Chuyến đi này đang lưu một phiên bản khác từ {date} ({distance}). Thay thế không đổi mục nhập hay văn bản nào.', + 'journey.recording.audience': + '{count} người khác trong chuyến đi sẽ thấy tuyến đường trên bản đồ. Liên kết chia sẻ không hiển thị nó.', + 'journey.recording.replace': 'Thay thế', + 'journey.recording.alreadyKept': 'Đã lưu', + 'journey.recording.keep': 'Lưu tuyến đường', + 'journey.recording.remove': 'Xóa tuyến đường', + 'journey.recording.removeTitle': 'Xóa tuyến đường đã lưu?', + 'journey.recording.removeMessage': + 'Tuyến đường của ngày này rời khỏi bản đồ chuyến đi. Mục nhập và văn bản vẫn còn, và Dawarich giữ bản ghi riêng của nó.', + 'journey.recording.keptToast': 'Đã lưu tuyến đường vào chuyến đi', + 'journey.recording.replacedToast': 'Đã thay tuyến đường', + 'journey.recording.removedToast': 'Đã xóa tuyến đường khỏi chuyến đi', + 'journey.recording.error.recording_exists': 'Chuyến đi này đã lưu một bản ghi khác cho ngày đó.', + 'journey.recording.error.recording_changed': 'Bản ghi đã thay đổi từ lúc xem trước.', + 'journey.recording.error.recording_empty': 'Dawarich không ghi lại gì vào ngày đó.', + 'journey.recording.mode.walking': 'Đi bộ', + 'journey.recording.mode.running': 'Chạy bộ', + 'journey.recording.mode.cycling': 'Xe đạp', + 'journey.recording.mode.driving': 'Ô tô', + 'journey.recording.mode.motorcycle': 'Xe máy', + 'journey.recording.mode.bus': 'Xe buýt', + 'journey.recording.mode.train': 'Tàu hỏa', + 'journey.recording.mode.flying': 'Chuyến bay', + 'journey.recording.mode.boat': 'Thuyền', + 'journey.recording.mode.stationary': 'Đứng yên', + 'journey.recording.mode.unknown': 'Phương tiện không rõ', }; export default journey; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index c6c8d6a77..11fff08f4 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -676,5 +676,58 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': '天氣', 'journey.entry.suggestion': '建議', 'journey.editor.addDetails': '+詳情', + 'journey.recording.mapLine': '記錄於 {date}', + 'journey.recording.keptBy': '已保存記錄的路線:{distance},由 {name} 於 {date} 匯入。', + 'journey.recording.keptOn': '已保存記錄的路線:{distance},於 {date} 匯入。', + 'journey.recording.keptRunning': '當時這一天還沒有結束。', + 'journey.recording.takeOver': '匯入記錄', + 'journey.recording.takeOverDay': '{date} 的記錄', + 'journey.recording.sub': '只匯入這一天,來自你的 Dawarich。確認之前不會保存任何內容。', + 'journey.recording.zone': '這一天的時區', + 'journey.recording.zoneHint': '這一天依該時區從午夜到午夜計算。請選擇你旅行時所在的時區。', + 'journey.recording.zoneSource': '來自 Dawarich', + 'journey.recording.zoneDevice': '本裝置', + 'journey.recording.scope': '記錄', + 'journey.recording.loading': '正在從 Dawarich 讀取這一天…', + 'journey.recording.keptStays': '這趟旅程保存的路線維持不變。', + 'journey.recording.retry': '重試', + 'journey.recording.empty': 'Dawarich 在該時區的這一天沒有任何記錄。', + 'journey.recording.span': '{from} 至 {to}', + 'journey.recording.pieces': '{count} 段', + 'journey.recording.gaps': '{count} 處中斷', + 'journey.recording.gapsHint': '記錄中斷的地方,線條也會中斷。不會畫出沒有記錄的路。', + 'journey.recording.sourceHint': '軌跡是 Dawarich 已依交通方式劃分的路線。點是原始位置,每天精簡到 600 個。', + 'journey.recording.sourceTracks': '軌跡', + 'journey.recording.sourcePoints': '點', + 'journey.recording.changedSincePreview': '你查看時 Dawarich 中的記錄有了變化。請再檢查一次,然後保存。', + 'journey.recording.truncated': 'Dawarich 只傳送了這一天的一部分,路線不完整。', + 'journey.recording.provisional': '在該時區這一天還沒有結束。之後可以用完整的一天取代路線。', + 'journey.recording.same': '這趟旅程已經保存了完全相同的記錄。', + 'journey.recording.differs': '這趟旅程保存了 {date} 的另一個版本({distance})。取代不會改動任何條目或文字。', + 'journey.recording.audience': '這趟旅程中的另外 {count} 人會在地圖上看到這條路線。共享連結不會顯示它。', + 'journey.recording.replace': '取代', + 'journey.recording.alreadyKept': '已保存', + 'journey.recording.keep': '保存路線', + 'journey.recording.remove': '移除路線', + 'journey.recording.removeTitle': '要移除已保存的路線嗎?', + 'journey.recording.removeMessage': + '這一天的路線將從旅程地圖上消失。條目和文字會保留,Dawarich 也會保留它自己的記錄。', + 'journey.recording.keptToast': '路線已保存到旅程', + 'journey.recording.replacedToast': '路線已取代', + 'journey.recording.removedToast': '路線已從旅程移除', + 'journey.recording.error.recording_exists': '這趟旅程已經保存了那一天的另一筆記錄。', + 'journey.recording.error.recording_changed': '自預覽以來記錄已有變化。', + 'journey.recording.error.recording_empty': 'Dawarich 那一天沒有任何記錄。', + 'journey.recording.mode.walking': '步行', + 'journey.recording.mode.running': '跑步', + 'journey.recording.mode.cycling': '騎單車', + 'journey.recording.mode.driving': '開車', + 'journey.recording.mode.motorcycle': '機車', + 'journey.recording.mode.bus': '公車', + 'journey.recording.mode.train': '火車', + 'journey.recording.mode.flying': '飛行', + 'journey.recording.mode.boat': '船', + 'journey.recording.mode.stationary': '靜止', + 'journey.recording.mode.unknown': '未知方式', }; export default journey; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 1cdfbaf44..e5d7bbb49 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -676,5 +676,58 @@ const journey: TranslationStrings = { 'journey.settings.showWeather': '天气', 'journey.entry.suggestion': '建议', 'journey.editor.addDetails': '+详情', + 'journey.recording.mapLine': '记录于 {date}', + 'journey.recording.keptBy': '已保存记录的路线:{distance},由 {name} 于 {date} 导入。', + 'journey.recording.keptOn': '已保存记录的路线:{distance},于 {date} 导入。', + 'journey.recording.keptRunning': '当时这一天还没有结束。', + 'journey.recording.takeOver': '导入记录', + 'journey.recording.takeOverDay': '{date} 的记录', + 'journey.recording.sub': '只导入这一天,来自你的 Dawarich。确认之前不会保存任何内容。', + 'journey.recording.zone': '这一天的时区', + 'journey.recording.zoneHint': '这一天按该时区从午夜到午夜计算。请选择你旅行时所在的时区。', + 'journey.recording.zoneSource': '来自 Dawarich', + 'journey.recording.zoneDevice': '本设备', + 'journey.recording.scope': '记录', + 'journey.recording.loading': '正在从 Dawarich 读取这一天…', + 'journey.recording.keptStays': '这次旅程保存的路线保持不变。', + 'journey.recording.retry': '重试', + 'journey.recording.empty': 'Dawarich 在该时区的这一天没有任何记录。', + 'journey.recording.span': '{from} 至 {to}', + 'journey.recording.pieces': '{count} 段', + 'journey.recording.gaps': '{count} 处中断', + 'journey.recording.gapsHint': '记录中断的地方,线条也会中断。不会画出没有记录的路。', + 'journey.recording.sourceHint': '轨迹是 Dawarich 已按出行方式划分的路线。点是原始位置,每天精简到 600 个。', + 'journey.recording.sourceTracks': '轨迹', + 'journey.recording.sourcePoints': '点', + 'journey.recording.changedSincePreview': '你查看时 Dawarich 中的记录发生了变化。请再检查一次,然后保存。', + 'journey.recording.truncated': 'Dawarich 只发送了这一天的一部分,路线不完整。', + 'journey.recording.provisional': '在该时区这一天还没有结束。之后可以用完整的一天替换路线。', + 'journey.recording.same': '这次旅程已经保存了完全相同的记录。', + 'journey.recording.differs': '这次旅程保存了 {date} 的另一个版本({distance})。替换不会改动任何条目或文字。', + 'journey.recording.audience': '这次旅程中的另外 {count} 人会在地图上看到这条路线。共享链接不会显示它。', + 'journey.recording.replace': '替换', + 'journey.recording.alreadyKept': '已保存', + 'journey.recording.keep': '保存路线', + 'journey.recording.remove': '移除路线', + 'journey.recording.removeTitle': '移除已保存的路线?', + 'journey.recording.removeMessage': + '这一天的路线将从旅程地图上消失。条目和文字会保留,Dawarich 也会保留它自己的记录。', + 'journey.recording.keptToast': '路线已保存到旅程', + 'journey.recording.replacedToast': '路线已替换', + 'journey.recording.removedToast': '路线已从旅程移除', + 'journey.recording.error.recording_exists': '这次旅程已经保存了那一天的另一条记录。', + 'journey.recording.error.recording_changed': '自预览以来记录已发生变化。', + 'journey.recording.error.recording_empty': 'Dawarich 那一天没有任何记录。', + 'journey.recording.mode.walking': '步行', + 'journey.recording.mode.running': '跑步', + 'journey.recording.mode.cycling': '骑行', + 'journey.recording.mode.driving': '驾车', + 'journey.recording.mode.motorcycle': '摩托车', + 'journey.recording.mode.bus': '公交', + 'journey.recording.mode.train': '火车', + 'journey.recording.mode.flying': '飞行', + 'journey.recording.mode.boat': '船', + 'journey.recording.mode.stationary': '静止', + 'journey.recording.mode.unknown': '未知方式', }; export default journey; diff --git a/shared/src/index.ts b/shared/src/index.ts index 274a76553..7e532c2c1 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -100,6 +100,7 @@ export * from './trip-invite/trip-invite.schema'; export * from './collab/collab.schema'; export * from './file/file.schema'; export * from './journey/journey.schema'; +export * from './journey/journey-recordings.schema'; export * from './journey/trip-journal-link.schema'; export * from './book/book.schema'; export * from './book/journey-stats.schema'; diff --git a/shared/src/journey/journey-recordings.schema.spec.ts b/shared/src/journey/journey-recordings.schema.spec.ts new file mode 100644 index 000000000..5a6459210 --- /dev/null +++ b/shared/src/journey/journey-recordings.schema.spec.ts @@ -0,0 +1,119 @@ +import { + journeyRecordingDateSchema, + journeyRecordingPreviewRequestSchema, + journeyRecordingSaveRequestSchema, + recordingStats, +} from './journey-recordings.schema'; + +import { describe, expect, it } from 'vitest'; + +const HASH = 'a'.repeat(64); + +describe('journeyRecordingDateSchema', () => { + it('JRNREC-SCHEMA-001: takes a calendar day and refuses one that does not exist or is spelt otherwise', () => { + expect(journeyRecordingDateSchema.safeParse('2026-05-14').success).toBe(true); + expect(journeyRecordingDateSchema.safeParse('2024-02-29').success).toBe(true); + expect(journeyRecordingDateSchema.safeParse('2026-02-30').success).toBe(false); + expect(journeyRecordingDateSchema.safeParse('2026-5-14').success).toBe(false); + expect(journeyRecordingDateSchema.safeParse('2026-05-14T00:00:00Z').success).toBe(false); + }); +}); + +describe('journeyRecordingPreviewRequestSchema', () => { + it('JRNREC-SCHEMA-002: an offset within the zones there are, the source zone optional', () => { + expect(journeyRecordingPreviewRequestSchema.safeParse({ date: '2026-05-14', utcOffsetMinutes: 120 }).success).toBe( + true, + ); + expect( + journeyRecordingPreviewRequestSchema.safeParse({ + date: '2026-05-14', + utcOffsetMinutes: -720, + useSourceOffset: true, + }).success, + ).toBe(true); + expect(journeyRecordingPreviewRequestSchema.safeParse({ date: '2026-05-14', utcOffsetMinutes: 841 }).success).toBe( + false, + ); + expect(journeyRecordingPreviewRequestSchema.safeParse({ date: '2026-05-14', utcOffsetMinutes: 90.5 }).success).toBe( + false, + ); + expect(journeyRecordingPreviewRequestSchema.safeParse({ date: '2026-05-14' }).success).toBe(false); + }); +}); + +describe('journeyRecordingSaveRequestSchema', () => { + it('JRNREC-SCHEMA-003: names the hash of the preview it confirms, replacing only when asked', () => { + expect(journeyRecordingSaveRequestSchema.parse({ utcOffsetMinutes: 0, expectedHash: HASH })).toEqual({ + utcOffsetMinutes: 0, + expectedHash: HASH, + }); + expect( + journeyRecordingSaveRequestSchema.safeParse({ utcOffsetMinutes: 0, expectedHash: HASH, replace: true }).success, + ).toBe(true); + expect(journeyRecordingSaveRequestSchema.safeParse({ utcOffsetMinutes: 0, expectedHash: 'abc' }).success).toBe( + false, + ); + expect(journeyRecordingSaveRequestSchema.safeParse({ utcOffsetMinutes: 0 }).success).toBe(false); + }); +}); + +describe('recordingStats', () => { + it('JRNREC-SCHEMA-004: adds up the pieces, prefers the distance Dawarich measured and counts the gaps between them', () => { + const stats = recordingStats([ + { + points: [ + [45.434, 12.3388], + [45.4345, 12.3392], + ], + mode: 'walking', + startedAt: '2026-05-14T09:00:00+02:00', + endedAt: '2026-05-14T09:20:00+02:00', + distanceMeters: 1200, + }, + // Starts a hair from where the walk ended: the recording joins up here. + { + points: [ + [45.43451, 12.33921], + [45.44, 12.35], + ], + mode: 'boat', + startedAt: '2026-05-14T09:30:00+02:00', + endedAt: '2026-05-14T10:00:00+02:00', + distanceMeters: null, + }, + // Kilometres away from the boat's end: a stretch with no recording. + { + points: [ + [45.5, 12.4], + [45.501, 12.401], + ], + mode: 'walking', + startedAt: '2026-05-14T15:00:00+02:00', + endedAt: '2026-05-14T15:10:00+02:00', + distanceMeters: 150, + }, + ]); + + expect(stats.segmentCount).toBe(3); + expect(stats.pointCount).toBe(6); + expect(stats.gapCount).toBe(1); + expect(stats.modes).toEqual(['walking', 'boat']); + expect(stats.firstAt).toBe('2026-05-14T09:00:00+02:00'); + expect(stats.lastAt).toBe('2026-05-14T15:10:00+02:00'); + // 1200 + the boat line's own length (about 1 km) + 150, and never the gap. + expect(stats.distanceMeters).toBeGreaterThan(2350); + expect(stats.distanceMeters).toBeLessThan(2450); + }); + + it('JRNREC-SCHEMA-005: an empty day covers nothing', () => { + expect(recordingStats([])).toEqual({ + distanceMeters: 0, + pointCount: 0, + segmentCount: 0, + gapCount: 0, + modes: [], + firstAt: null, + lastAt: null, + }); + }); +}); diff --git a/shared/src/journey/journey-recordings.schema.ts b/shared/src/journey/journey-recordings.schema.ts new file mode 100644 index 000000000..32f5ba503 --- /dev/null +++ b/shared/src/journey/journey-recordings.schema.ts @@ -0,0 +1,193 @@ +import { dawarichTrackSegmentSchema, type DawarichTrackSegment } from '../dawarich/dawarich.schema'; +import { haversineMetres } from '../geo/haversine'; + +import { z } from 'zod'; + +/** + * JRN-01: the route recorded on one day of a journey, taken over from Dawarich + * on purpose and kept by the journey. + * + * The Dawarich overlay is a live read that is forgotten again. A recorded day + * is the exception the traveller asks for: a preview first, then a confirmed + * copy of that one day, thinned like the overlay, that stays drawn when the + * instance is down or the connection is gone. Its pieces keep the gaps of the + * recording and the mode Dawarich gave them; nothing joins two pieces the + * recording did not join. + * + * A day is cut in one zone, chosen in the preview: Dawarich's own when it + * reveals one, else the one the traveller picks. + */ + +/** Where a recorded day came from. One source today; the column is there for a second. */ +export const JOURNEY_RECORDING_SOURCES = ['dawarich'] as const; + +/** `YYYY-MM-DD`, a calendar day that exists. */ +export const journeyRecordingDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => { + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; + }, 'Not a calendar day'); + +/** A zone offset in minutes east of UTC, as far as zones go (UTC-12 to UTC+14). */ +export const utcOffsetMinutesSchema = z.number().int().min(-720).max(840); + +/** What a recorded day covers, worked out from its pieces. */ +export const journeyRecordingStatsSchema = z.object({ + /** Metres along the pieces; a gap between two pieces adds nothing. */ + distanceMeters: z.number(), + pointCount: z.number(), + segmentCount: z.number(), + /** Stretches the recording does not cover: two pieces that do not meet. */ + gapCount: z.number(), + /** The modes Dawarich gave the pieces, each once, in the order they were first travelled. */ + modes: z.array(z.string()), + firstAt: z.string().nullable(), + lastAt: z.string().nullable(), +}); +export type JourneyRecordingStats = z.infer; + +const recordingWindowSchema = z.object({ + /** The instant the local day began, ISO-8601. */ + from: z.string(), + /** The instant it ended. */ + to: z.string(), +}); + +/** A recorded day as the journey keeps it. */ +export const journeyRecordedDaySchema = z.object({ + id: z.number(), + date: z.string(), + source: z.enum(JOURNEY_RECORDING_SOURCES), + /** Which Dawarich endpoint answered: pre-segmented tracks or thinned points. */ + sourceKind: z.enum(['tracks', 'points']), + utcOffsetMinutes: z.number(), + window: recordingWindowSchema, + segments: z.array(dawarichTrackSegmentSchema), + stats: journeyRecordingStatsSchema, + /** The source hit its page cap and the day is incomplete. */ + truncated: z.boolean(), + /** The day was not over yet in its zone when it was taken over. */ + provisional: z.boolean(), + /** When the source answered. */ + fetchedAt: z.string(), + contentHash: z.string(), + /** Whose recording it is; null once that account is gone. */ + importedBy: z.object({ id: z.number(), username: z.string() }).nullable(), + /** When this version was taken over, ISO-8601. */ + savedAt: z.string(), +}); +export type JourneyRecordedDay = z.infer; + +export const journeyRecordedDaysResponseSchema = z.object({ + days: z.array(journeyRecordedDaySchema), +}); +export type JourneyRecordedDaysResponse = z.infer; + +export const journeyRecordingPreviewRequestSchema = z.object({ + date: journeyRecordingDateSchema, + /** The zone to cut the day in when the source names none, or when `useSourceOffset` is off. */ + utcOffsetMinutes: utcOffsetMinutesSchema, + /** Cut the day in the zone Dawarich records in, where it reveals one. */ + useSourceOffset: z.boolean().optional(), +}); +export type JourneyRecordingPreviewRequest = z.infer; + +/** + * What taking a day over would keep, read live and written nowhere. Saving + * sends `contentHash` back, so what is kept is what was looked at. + */ +export const journeyRecordingPreviewSchema = z.object({ + journey: z.object({ id: z.number(), title: z.string() }), + date: z.string(), + utcOffsetMinutes: z.number(), + /** `source`: the zone Dawarich records in. `chosen`: the one asked for. */ + offsetSource: z.enum(['source', 'chosen']), + /** Dawarich's own zone where its answer reveals one, else null. */ + sourceOffsetMinutes: z.number().nullable(), + window: recordingWindowSchema, + sourceKind: z.enum(['tracks', 'points']), + segments: z.array(dawarichTrackSegmentSchema), + stats: journeyRecordingStatsSchema, + truncated: z.boolean(), + provisional: z.boolean(), + fetchedAt: z.string(), + /** Null when nothing was recorded on the day: there is nothing to keep. */ + contentHash: z.string().nullable(), + /** The version the journey keeps for this day, if any. */ + stored: journeyRecordedDaySchema.nullable(), + /** The live recording is the one already kept. */ + sameAsStored: z.boolean(), + /** How many other people can open the journey and would see the route. */ + audience: z.number(), +}); +export type JourneyRecordingPreview = z.infer; + +export const journeyRecordingSaveRequestSchema = z.object({ + utcOffsetMinutes: utcOffsetMinutesSchema, + /** The `contentHash` of the preview that was confirmed. */ + expectedHash: z.string().regex(/^[0-9a-f]{64}$/), + /** Replace a different version the journey already keeps for the day. */ + replace: z.boolean().optional(), +}); +export type JourneyRecordingSaveRequest = z.infer; + +export const journeyRecordingSaveResultSchema = z.object({ + day: journeyRecordedDaySchema, + /** `unchanged`: the day was kept already, exactly so, and nothing was written. */ + outcome: z.enum(['created', 'replaced', 'unchanged']), +}); +export type JourneyRecordingSaveResult = z.infer; + +/** Two pieces closer than this meet; farther apart, the recording has a gap between them. */ +export const RECORDING_GAP_METERS = 250; + +/** + * A point as `[lat, lng]`. Read as a plain array because the client compiles + * these sources without strictNullChecks, where the schema's tuple reads as two + * optional numbers and a rest of anything. + */ +type LatLng = readonly unknown[]; + +const coord = (value: unknown): number => (typeof value === 'number' ? value : 0); + +function metresBetween(a: LatLng, b: LatLng): number { + return haversineMetres(coord(a[0]), coord(a[1]), coord(b[0]), coord(b[1])); +} + +function lengthOf(points: readonly LatLng[]): number { + let total = 0; + for (let i = 1; i < points.length; i++) total += metresBetween(points[i - 1]!, points[i]!); + return total; +} + +/** + * What a day's pieces cover. A piece's own distance is Dawarich's measurement + * and wins over the thinned line's length, which is always a little short. + */ +export function recordingStats(segments: DawarichTrackSegment[]): JourneyRecordingStats { + let distanceMeters = 0; + let pointCount = 0; + let gapCount = 0; + const modes: string[] = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]!; + distanceMeters += segment.distanceMeters ?? lengthOf(segment.points); + pointCount += segment.points.length; + if (segment.mode && !modes.includes(segment.mode)) modes.push(segment.mode); + const previous = segments[i - 1]; + const end = previous?.points[previous.points.length - 1]; + const start = segment.points[0]; + if (end && start && metresBetween(end, start) > RECORDING_GAP_METERS) gapCount++; + } + return { + distanceMeters: Math.round(distanceMeters), + pointCount, + segmentCount: segments.length, + gapCount, + modes, + firstAt: segments[0]?.startedAt ?? null, + lastAt: segments[segments.length - 1]?.endedAt ?? null, + }; +} diff --git a/shared/src/journey/journey.schema.ts b/shared/src/journey/journey.schema.ts index 8075eaed6..9ac625a86 100644 --- a/shared/src/journey/journey.schema.ts +++ b/shared/src/journey/journey.schema.ts @@ -182,6 +182,12 @@ export const journeyTrackSchema = z.object({ name: z.string(), color: z.string().nullable(), points: z.array(z.tuple([z.number(), z.number()])), + /** + * What the line is (JRN-01): a GPX track of a trip, planned; a day recording + * the journey keeps; or the live Dawarich trail. Absent means a trip's GPX, + * which is all GET `:id/tracks` has ever answered. + */ + kind: z.enum(['trip_gpx', 'day_recording', 'dawarich_live']).optional(), }); export type JourneyTrack = z.infer; diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 8c8377b3e..67c393fba 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -127,6 +127,7 @@ const FIXTURES: Record> = { 'journey:book:cursor': { journeyId: 7, socketId: 3, userId: 2, spreadIndex: 0, x: 105.5, y: 60 }, 'journey:contributor:changed': { journeyId: 3, targetUserId: 2, role: 'editor' }, 'journey:photos:updated': { journeyId: 3 }, + 'journey:recording:updated': { journeyId: 3, date: '2026-05-14' }, 'import:progress': { jobId: 'j1', tripId: 1, status: 'running', done: 1, total: 3, fileName: 'a.pdf' }, 'import:done': { jobId: 'j1', tripId: 1, result: { items: [] } }, 'import:error': { jobId: 'j1', tripId: 1, message: 'boom' }, @@ -149,7 +150,7 @@ const DRIFT_VARIANTS: Partial[]> }; describe('@trek/shared realtime event registry', () => { - it('WSEVT-REG-001: pins the authoritative inventory counts (77 trip + 33 user = 110)', () => { + it('WSEVT-REG-001: pins the authoritative inventory counts (77 trip + 34 user = 111)', () => { // 67th to 69th trip event: the three collab:link:* a shared link emits. // 70th and 71st: the road trip's vias and tracks, which used to be written silently. // 74th: docsync:changed, so a sync run that moved documents refreshes the @@ -160,9 +161,11 @@ describe('@trek/shared realtime event registry', () => { // days show the same for every member of the trip. // 33rd user event: journey:photos:updated, so the gallery re-sorts once the // capture times of an import have landed (#1587). + // 34th: journey:recording:updated, so every map of a journey shows a day's + // recorded route once somebody took it over (JRN-01). expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(77); - expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(33); - expect(TREK_WS_EVENT_NAMES).toHaveLength(110); + expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(34); + expect(TREK_WS_EVENT_NAMES).toHaveLength(111); }); it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index aee678e9b..98e474c18 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -339,6 +339,11 @@ export const TREK_WS_EVENTS = { * request would reload every open client once per uploaded photo. */ 'journey:photos:updated': { scope: 'user', payload: z.object({ journeyId: id }) }, + /* + * A day's recorded route was taken over, replaced or removed (JRN-01). Sent + * after the write to everyone on the journey; the map reads the days again. + */ + 'journey:recording:updated': { scope: 'user', payload: z.object({ journeyId: id, date: z.string() }) }, /* * The Studio book was saved (#1973). diff --git a/wiki/Dawarich.md b/wiki/Dawarich.md index e47839657..7a5bbf506 100644 --- a/wiki/Dawarich.md +++ b/wiki/Dawarich.md @@ -129,10 +129,12 @@ The route is drawn, never applied: TREK does not correct your plan from it. A journal draws the same route on its own map, from the first to the last date of the trips linked to it, when **Show all trip GPX tracks** is on in the journal's **Journey Settings**. -### Nothing is stored +### Nothing is stored, unless you keep a day The route is read from Dawarich when a map asks for it and then discarded. TREK's server holds it in memory for one minute, so panning and toggling do not ask your instance again, and forgets it on a restart or when you change the connection. No position is written to TREK's database, so the route is not in backups and is not available offline. +The one exception is asked for: in a journal you can keep the route of one day on purpose, after a preview you confirm. That day is thinned the same way, keeps the gaps and modes of the recording, and belongs to the journal from then on, so it stays drawn when the connection is gone. See [Keeping a recorded day](Journey-Journal#keeping-a-recorded-day). + TREK prefers the tracks Dawarich has already generated, which arrive split into segments with a travel mode. On an instance that has not generated tracks yet, it falls back to the raw points and thins them to 600 per day, so a month-long trip stays drawable. ## Bucket list and Atlas @@ -174,6 +176,8 @@ With the [MCP](MCP-Overview) addon on as well, an assistant can review your stay | `mark_bucket_list_item_visited_from_dawarich` | Ticks off the wish a stay was linked to, or another wish of yours. | `atlas:write` | | `dismiss_dawarich_suggestion` | Dismisses a stay, or puts a dismissed one back. | `journey:write` | | `get_dawarich_trip_track` | Summarises a trip's recorded route per day: segments, times, travel mode and distance. The line itself stays on the map. | `journey:read` | +| `preview_journey_recorded_day` | Reads one day of your recording and says what a journal would keep, without keeping it. Needs the Journey addon too. | `journey:read` | +| `save_journey_recorded_day` | Keeps that day in a journal after you confirmed the preview, or replaces a different version when asked. Needs the Journey addon too. | `journey:write` | The same rules apply as in the browser: nothing is added until a tool is called, and the permissions for adding a place are checked the same way. See [MCP-Addon-Tools](MCP-Addon-Tools) and [MCP-Scopes](MCP-Scopes). diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index f46fb9158..270e4d6b1 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -100,9 +100,28 @@ The **+** in the bottom bar starts a quick capture for a moment on the move: a t The journey detail page includes a map on the right (desktop) or an integrated map-timeline (mobile) showing all entry locations alongside the places from linked trips. -**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. Nothing to switch on: the tracks belong to the trips your entries came from, so importing the file while planning is all it takes. Hovering a track shows its name. +**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. Nothing to switch on: the tracks belong to the trips your entries came from, so importing the file while planning is all it takes. Hovering a track shows its name. A trip's track is the plan, so it is drawn dashed; what was recorded, a day the journey keeps or the live [Dawarich](Dawarich) route, is drawn solid. -The thin dashed line connecting entries in date order is something else and stays as it is: that one is drawn by TREK, while a track is the route you actually recorded. +The thin dashed line connecting entries in date order is something else and stays as it is: that one is drawn by TREK, while a track is a route. + +### Keeping a recorded day + +With a [Dawarich](Dawarich) connection, a journey can keep the route you actually travelled on one of its days, so it stays on the map after the connection is gone or the instance is down. + +1. In the timeline, the header of each day carries the Dawarich mark. Click it (on a phone: **More options**, then **Recording of** the day of the card you are on). +2. The dialog names the journey and the day and reads that day from your Dawarich. Nothing is kept yet. +3. **Time zone of the day** decides where the day starts and ends. TREK starts with the zone Dawarich records in when its answer shows one, else the zone of your device; pick the one you were travelling in. +4. Check what the day covers: a sketch of the route, the distance, the first and last time, the pieces and the gaps in the recording, the modes Dawarich gave them, and whether it came as tracks or as thinned points. +5. **Keep route** keeps it. The day header then shows the distance, and the route appears on the journey map in the colour of that day's pins. + +What you should know: + +- **Gaps stay gaps.** Where the recording stops, the line stops too; TREK never draws a way between two pieces that was not recorded. +- **Taking the same day over again keeps no second copy.** When Dawarich now holds a different version (a day that was still running, a recording that was fixed), the dialog says so and **Replace** swaps it. Replacing never touches entries or texts. +- **What you keep is what you saw.** If the recording changes in Dawarich while the dialog is open, nothing is kept and the dialog shows the new version first. +- **A failed read changes nothing.** The version the journey keeps stays as it is, and the dialog offers **Try again**. +- **Everyone on the journey sees the route**, also when an editor kept their own recording; the dialog says how many people that is. Shared links do not show recorded days. +- **Remove route** in the dialog takes the day off the journey again, also without a Dawarich connection. Dawarich keeps its own recording either way. **Geotagged photos are drawn too.** A photo of the journey that knows where it was taken appears on the map as a thumbnail, and photos taken close together collapse into one thumbnail with a count. Click one to open those photos in the viewer. diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index d15c5d813..22225b454 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -207,6 +207,8 @@ Requires `journey:read` or `journey:write` scope. | `get_journey_share_link` | Get the current public share link for a journey. Requires `journey:share`. | | `create_journey_share_link` | Create or update the public share link for a journey. Requires `journey:share`. | | `delete_journey_share_link` | Revoke the public share link for a journey. Requires `journey:share`. | +| `list_journey_recorded_days` | The days of recorded route a journey keeps: zone, distance, pieces, gaps, modes, whether the day was still running, and whose recording it is, without the geometry. Readable without a Dawarich connection. | +| `delete_journey_recorded_day` | Drop the recorded route a journey keeps for one day. Entries and texts stay, and Dawarich keeps its own recording. Needs edit access. | ### Dawarich _(Dawarich addon required)_ @@ -220,6 +222,8 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T | `mark_bucket_list_item_visited_from_dawarich` | Tick off the bucket list wish a stay was matched to, or another wish of the caller's | `atlas:write` | | `dismiss_dawarich_suggestion` | Dismiss a stay, or put a dismissed one back | `journey:write` | | `get_dawarich_trip_track` | Summary of a trip's recorded route per local day: segments, times, travel mode, distance and point counts, without the geometry. Read live from Dawarich, nothing stored | `journey:read` | +| `preview_journey_recorded_day` | Read one local day of the caller's recording and show what a journey would keep, without keeping it: the window the day runs in, distance, pieces, gaps, modes, whether the journey keeps the day already and how many other people would see it, and the `contentHash` the save needs. Needs the Journey addon too and edit access to the journey | `journey:read` | +| `save_journey_recorded_day` | Keep that day in the journey after the traveller confirmed the preview. Send its `utcOffsetMinutes` and `contentHash` as `expectedHash`; a recording that changed since is not kept. A different version is replaced only with `replace: true`; entries and texts are never touched. Needs the Journey addon too | `journey:write` | ### Document sync _(Documents addon required)_