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

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

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

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

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
This commit is contained in:
Maurice
2026-10-21 15:05:21 +02:00
parent cbca3b2851
commit 65bb2d95eb
99 changed files with 6359 additions and 51 deletions
@@ -0,0 +1,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<Journeys>;
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<Users> | 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'),
},
});
@@ -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<JourneyContributors>(this);
journey_entries_collection = new Collection<JourneyEntries>(this);
journey_photos_collection = new Collection<JourneyPhotos>(this);
journey_recorded_days_collection = new Collection<JourneyRecordedDays>(this);
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
journey_trips_collection = new Collection<JourneyTrips>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(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(),
+3
View File
@@ -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<JourneyBooks>(this);
journey_contributors_collection = new Collection<JourneyContributors>(this);
journey_entries_collection = new Collection<JourneyEntries>(this);
journey_recorded_days_collection = new Collection<JourneyRecordedDays>(this);
journey_share_tokens_collection = new Collection<JourneyShareTokens>(this);
journeys_collection = new Collection<Journeys>(this);
mcp_tokens_collection = new Collection<McpTokens>(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(),
+2
View File
@@ -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,
+13
View File
@@ -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<TourSourceHintChange[]> = {
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<DawarichTrackSegment[]> = {
column: 'journey_recorded_days.segments',
schema: z.array(dawarichTrackSegmentSchema),
fallback: () => [],
};
@@ -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<number>;
journey_id: number;
local_date: string;
source: InsertOptional<string>;
source_kind: string;
utc_offset_minutes: number;
window_from: string;
window_to: string;
segments: string;
point_count: number;
distance_m: InsertOptional<number | null>;
content_hash: string;
truncated: InsertOptional<number>;
fetched_at: string;
imported_by: InsertOptional<number | null>;
created_at: InsertOptional<string>;
updated_at: InsertOptional<string>;
}
+2
View File
@@ -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;
@@ -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)',
);
}
}
@@ -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<DB, 'journey_recorded_days' | 'users'>;
/** 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<JourneyRecordedDays> {
private db() {
return this.kysely<RecordedDaysDB>();
}
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<JourneyRecordedDayRow[]> {
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<JourneyRecordedDayRow | undefined> {
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<number> {
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<boolean> {
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;
}
}