mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RecordedDayFetch> {
|
||||
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: [],
|
||||
|
||||
@@ -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<T extends TimedPoint>(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<BucketOptions> = {},
|
||||
): DawarichTrackDay[] {
|
||||
const { maxPointsPerDay, epsilon } = { ...DEFAULT_BUCKET, ...options };
|
||||
const byDay = new Map<string, Array<{ lat: number; lng: number; ts: number }>>();
|
||||
const { maxPointsPerDay, epsilon, gaps } = { ...DEFAULT_BUCKET, ...options };
|
||||
const byDay = new Map<string, TimedPoint[]>();
|
||||
|
||||
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<BucketOptions, 'maxPointsPerDay' | 'epsilon'>,
|
||||
): 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<BucketOptions> = {},
|
||||
): DawarichTrackDay[] {
|
||||
const { maxPointsPerDay, epsilon } = { ...DEFAULT_BUCKET, ...options };
|
||||
const { maxPointsPerDay, epsilon, offsetMinutes } = { ...DEFAULT_BUCKET, ...options };
|
||||
const byDay = new Map<string, DawarichTrackSegment[]>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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<boolean> =>
|
||||
(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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<JourneyRecordedDay[]> {
|
||||
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<JourneyRecordingPreview> {
|
||||
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<JourneyRecordingSaveResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DAWARICH))) {
|
||||
throw notFound('Dawarich is not enabled', { code: 'addon_disabled' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<JourneyRecordedDay[]> {
|
||||
return (await this.days.listForJourney(journeyId)).map(toRecordedDay);
|
||||
}
|
||||
|
||||
async find(journeyId: number, date: string): Promise<JourneyRecordedDay | null> {
|
||||
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<JourneyRecordedDay> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user