From 906eef43498efec8f086d3e63facdfbb359bc4c0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:34:34 +0200 Subject: [PATCH] feat(shared): add Dawarich contracts Zod schemas for the Dawarich integration: connection settings, the suggestion list and its accept payloads, the recorded-track window and the Atlas country/city proposals. Places gain an optional `source` so a place that came out of a recording can say so. --- shared/src/dawarich/dawarich.schema.ts | 431 +++++++++++++++++++++++++ shared/src/index.ts | 1 + shared/src/place/place.schema.ts | 6 + 3 files changed, 438 insertions(+) create mode 100644 shared/src/dawarich/dawarich.schema.ts diff --git a/shared/src/dawarich/dawarich.schema.ts b/shared/src/dawarich/dawarich.schema.ts new file mode 100644 index 000000000..612379e23 --- /dev/null +++ b/shared/src/dawarich/dawarich.schema.ts @@ -0,0 +1,431 @@ +import { z } from 'zod'; +import { idSchema } from '../common/primitives.schema'; + +/** + * Dawarich integration contracts (#2279, #214). + * + * Dawarich (github.com/Freika/dawarich) is a self-hosted location-history + * tracker. The split of duties is deliberate and settled: **Dawarich records, + * 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. + * + * 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, + * which reaches exactly that user's own recordings. + * + * Everything Dawarich produces arrives as a **suggestion owned by the person + * who connected the instance**. Nothing reaches a shared trip until they accept + * it, because a background poll that edits a trip three other people are + * planning is not a feature. + */ + +// ── Per-user connection ────────────────────────────────────────────────────── + +/** Placeholder returned instead of the stored key. Never the key itself. */ +export const DAWARICH_KEY_MASK = '••••••••'; + +export const dawarichSettingsSchema = z.object({ + /** Instance origin, e.g. https://dawarich.example.com — TREK appends /api/v1 itself. */ + url: z.string().trim().max(2048), + /** API key. Omitted, blank or the mask keeps the stored key unchanged. */ + apiKey: z.string().max(512).optional(), + /** Allow self-signed TLS certificates, which LAN instances routinely use. */ + allowInsecureTls: z.boolean().optional().default(false), + /** + * Poll for new visits in the background. Off means the user pulls by hand — + * some people would rather their trip planner not talk to their location + * history on a timer. + */ + syncEnabled: z.boolean().optional().default(true), +}); +export type DawarichSettings = z.infer; + +/** + * What the connected instance actually offers, probed once per connect and + * re-probed on every successful sync. + * + * This exists because three things were discussed with Dawarich's maintainer + * and none of them had shipped as of 1.14.4: an `updated_at` on a visit, a + * status filter on the visits endpoint, and an ISO-3166 country code on the + * visit's place. TREK works without all three — change detection falls back to + * a content hash, status filtering happens here, and the country is derived + * from the coordinates — and picks the better path up automatically if a later + * Dawarich version starts sending them. A missing capability is never a reason + * to refuse to connect. + */ +export const dawarichCapabilitiesSchema = z.object({ + /** `GET /api/v1/visits` answered. Without it there are no suggestions. */ + visits: z.boolean(), + /** `GET /api/v1/tracks` answered — pre-segmented lines, the good overlay source. */ + tracks: z.boolean(), + /** `GET /api/v1/points` answered — the fallback overlay source. */ + points: z.boolean(), + /** `GET /api/v1/locations` answered — used to tick off bucket-list entries. */ + locations: z.boolean(), + /** `GET /api/v1/countries/visited_cities` answered — feeds the Atlas. */ + visitedCities: z.boolean(), + /** A visit carries `updated_at`. As of 1.14.4 it does not. */ + visitUpdatedAt: z.boolean(), + /** A visit's place carries a country code. As of 1.14.4 it does not. */ + visitCountryCode: z.boolean(), + /** Reported by `X-Dawarich-Version`, so it is the instance's own word. */ + serverVersion: z.string().nullable(), + /** When this probe ran, ISO-8601. */ + probedAt: z.string(), +}); +export type DawarichCapabilities = z.infer; + +/** How the last sync attempt ended — what the connection card reports. */ +export const DAWARICH_SYNC_STATES = ['never', 'ok', 'partial', 'failed'] as const; +export const dawarichSyncStateSchema = z.enum(DAWARICH_SYNC_STATES); +export type DawarichSyncState = z.infer; + +export const dawarichConnectionSchema = z.object({ + url: z.string(), + apiKeyMasked: z.string(), + allowInsecureTls: z.boolean(), + syncEnabled: z.boolean(), + connected: z.boolean(), + /** ISO-8601 of the last sync that finished, successfully or not. */ + lastSyncAt: z.string().nullable(), + lastSyncState: dawarichSyncStateSchema, + /** + * Why the last sync did not fully succeed, already translated by the server + * into one of the reason codes below. Null when it did. + */ + lastSyncError: z.string().nullable(), + capabilities: dawarichCapabilitiesSchema.nullable(), +}); +export type DawarichConnection = z.infer; + +/** + * Why a Dawarich call failed, as a code rather than a sentence. + * + * The client renders these through i18n. A raw upstream message would reach the + * user in English on a German install, and some of them quote the URL back. + */ +export const DAWARICH_ERROR_CODES = [ + 'unreachable', + 'unauthorized', + 'forbidden', + 'not_found', + 'rate_limited', + 'server_error', + 'invalid_response', + 'too_large', + 'not_connected', + 'addon_disabled', +] as const; +export const dawarichErrorCodeSchema = z.enum(DAWARICH_ERROR_CODES); +export type DawarichErrorCode = z.infer; + +export const dawarichStatusSchema = z.object({ + connected: z.boolean(), + /** Visits found in the probe window, so the user sees the connection did something. */ + visitCount: z.number().optional(), + error: dawarichErrorCodeSchema.optional(), + /** Free-text detail for the cases where the code alone is not actionable. */ + errorDetail: z.string().optional(), + capabilities: dawarichCapabilitiesSchema.optional(), +}); +export type DawarichStatus = z.infer; + +// ── Visit suggestions ──────────────────────────────────────────────────────── + +/** + * What Dawarich thinks about a visit. Since 1.12.0 declining a visit **deletes** + * it, so only these two remain — and, crucially, an unconfirmed visit is + * displayed in Dawarich exactly like a confirmed one. Importing only + * `confirmed` would leave most instances looking empty, which is the failure + * mode the maintainer specifically warned about. + */ +export const DAWARICH_VISIT_STATUSES = ['suggested', 'confirmed'] as const; +export const dawarichVisitStatusSchema = z.enum(DAWARICH_VISIT_STATUSES); +export type DawarichVisitStatus = z.infer; + +/** Where a suggestion stands in TREK. Independent of what Dawarich thinks of it. */ +export const DAWARICH_SUGGESTION_STATES = ['new', 'accepted', 'dismissed'] as const; +export const dawarichSuggestionStateSchema = z.enum(DAWARICH_SUGGESTION_STATES); +export type DawarichSuggestionState = z.infer; + +/** What accepting a suggestion produced. */ +export const DAWARICH_SUGGESTION_TARGETS = ['place', 'journal', 'bucket_list'] as const; +export const dawarichSuggestionTargetSchema = z.enum(DAWARICH_SUGGESTION_TARGETS); +export type DawarichSuggestionTarget = z.infer; + +/** + * One recorded stay, as TREK holds it for review. + * + * `source_changed` and `source_missing` are the honest answer to a question the + * API cannot answer directly: Dawarich sends no `updated_at`, and deleting a + * visit removes it from the list rather than marking it. So a changed visit is + * detected by comparing a hash of the fields TREK shows, and a removed one by + * noticing it was absent from a full re-read of the same window. Neither ever + * rewrites something the user already accepted and possibly edited — they raise + * a flag and leave the decision where it belongs. + */ +export const dawarichSuggestionSchema = z.object({ + id: idSchema, + /** Dawarich's own visit id. The dedup key, together with the user. */ + sourceVisitId: z.string(), + /** The trip whose dates this stay fell into when it was first seen. */ + tripId: idSchema.nullable(), + tripTitle: z.string().nullable(), + name: z.string(), + lat: z.number().nullable(), + lng: z.number().nullable(), + /** ISO-8601 with offset, exactly as Dawarich reported it. */ + startedAt: z.string(), + endedAt: z.string(), + /** The stay's length in minutes, the number the review list sorts and filters on. */ + durationMinutes: z.number(), + /** + * The local calendar date this stay belongs to, resolved in the trip's own + * timezone. A stay that starts at 23:40 belongs to that evening, not to the + * next morning because UTC says so. + */ + localDate: z.string(), + sourceStatus: dawarichVisitStatusSchema, + /** + * Dawarich's own confidence in the detection, 0..100. Null when its detector + * reported none — older rows and manually created visits have none. + */ + confidence: z.number().nullable(), + /** + * The same number as a word: `high` (>= 70), `medium` (>= 40), `low`. + * Kept as a plain string rather than an enum, so a band a later Dawarich adds + * arrives intact instead of failing validation for the whole suggestion. + */ + confidenceBand: z.string().nullable(), + state: dawarichSuggestionStateSchema, + target: dawarichSuggestionTargetSchema.nullable(), + acceptedPlaceId: idSchema.nullable(), + acceptedJournalEntryId: idSchema.nullable(), + acceptedBucketListItemId: idSchema.nullable(), + /** The source row changed since this was accepted — shown, never auto-applied. */ + sourceChanged: z.boolean(), + /** The source row is gone from Dawarich — shown, never auto-deleted. */ + sourceMissing: z.boolean(), + /** A bucket-list entry this stay sits on top of, if one matched. */ + matchedBucketListItemId: idSchema.nullable(), + matchedBucketListName: z.string().nullable(), + /** ISO-3166-1 alpha-2, derived from the coordinates — Dawarich sends no code. */ + countryCode: z.string().nullable(), + firstSeenAt: z.string(), + lastSeenAt: z.string(), +}); +export type DawarichSuggestion = z.infer; + +export const dawarichSuggestionListSchema = z.object({ + suggestions: z.array(dawarichSuggestionSchema), + /** Repeated here so a panel can show provenance without a second request. */ + connected: z.boolean(), + lastSyncAt: z.string().nullable(), + lastSyncState: dawarichSyncStateSchema, + lastSyncError: z.string().nullable(), +}); +export type DawarichSuggestionList = z.infer; + +/** + * Accepting a suggestion. Every field the user could correct before saving is + * here, because the review step exists precisely so a detector's guess can be + * fixed rather than swallowed. + */ +export const dawarichAcceptSchema = z.object({ + target: dawarichSuggestionTargetSchema, + /** Required for `place`; optional context for `journal`. */ + tripId: idSchema.optional(), + /** Which day of the trip the place lands on. Omitted leaves it unplanned. */ + dayId: idSchema.optional(), + /** Required for `journal`. */ + journalId: idSchema.optional(), + /** Required for `bucket_list` — which wish this stay ticks off. */ + bucketListItemId: idSchema.optional(), + /** Corrected name. Falls back to the suggestion's own. */ + name: z.string().trim().min(1).max(255).optional(), + notes: z.string().max(5000).optional(), + lat: z.number().min(-90).max(90).optional(), + lng: z.number().min(-180).max(180).optional(), + /** `YYYY-MM-DD`. Falls back to the suggestion's resolved local date. */ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + /** `HH:MM`. Falls back to the recorded arrival and departure. */ + time: z.string().regex(/^\d{2}:\d{2}$/).optional(), + endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(), +}); +export type DawarichAccept = z.infer; + +export const dawarichAcceptResultSchema = z.object({ + suggestion: dawarichSuggestionSchema, + /** What was created, so the caller can navigate straight to it. */ + createdPlaceId: idSchema.nullable(), + createdJournalEntryId: idSchema.nullable(), + bucketListItemId: idSchema.nullable(), +}); +export type DawarichAcceptResult = z.infer; + +/** Putting a dismissed or accepted suggestion back into review. */ +export const dawarichSuggestionStateSchemaBody = z.object({ + state: z.enum(['new', 'dismissed']), +}); +export type DawarichSuggestionStateBody = z.infer; + +// ── Track overlay ──────────────────────────────────────────────────────────── + +/** + * How a segment was travelled, as Dawarich classified it. Passed through rather + * than mapped onto TREK's own transport modes: the two vocabularies do not line + * up (Dawarich has `stationary`, TREK has `ferry`), and inventing a mapping + * would put a confident wrong label on a recorded fact. + */ +export const dawarichTrackSegmentSchema = z.object({ + /** `[lat, lng]` pairs, matching how TREK stores every other track. */ + points: z.array(z.tuple([z.number(), z.number()])), + /** Dawarich's `dominant_mode`, or null on an instance too old to report one. */ + mode: z.string().nullable(), + startedAt: z.string(), + endedAt: z.string(), + distanceMeters: z.number().nullable(), +}); +export type DawarichTrackSegment = z.infer; + +/** One local calendar day of recorded movement. */ +export const dawarichTrackDaySchema = z.object({ + /** `YYYY-MM-DD` in the trip's timezone. */ + date: z.string(), + segments: z.array(dawarichTrackSegmentSchema), +}); +export type DawarichTrackDay = z.infer; + +/** + * The recorded route for a window, as fetched — not as stored. Nothing here is + * written to TREK's database; the next request asks Dawarich again. + */ +export const dawarichTrackSchema = z.object({ + days: z.array(dawarichTrackDaySchema), + /** + * Which endpoint answered. `tracks` is pre-segmented and cheap; `points` is + * the fallback for an instance that has not generated tracks yet, thinned + * before it leaves the server. + */ + source: z.enum(['tracks', 'points']), + /** ISO-8601 of the fetch, so the map can say how fresh the line is. */ + fetchedAt: z.string(), + /** Points after thinning, across all days. */ + pointCount: z.number(), + /** The window hit the page cap and the line is incomplete. Said out loud, never hidden. */ + truncated: z.boolean(), +}); +export type DawarichTrack = z.infer; + +// ── Atlas ──────────────────────────────────────────────────────────────────── + +/** + * A country Dawarich's recordings say the user was in, offered to the Atlas. + * + * Offered, not applied: the Atlas is a thing people curate, and a poll that + * quietly ticks off countries would overwrite a deliberate choice. Entries + * already marked by hand come back with `alreadyVisited` so they are shown as + * confirmation rather than as something new. + */ +export const dawarichAtlasCountrySchema = z.object({ + /** ISO-3166-1 alpha-2, resolved from Dawarich's country name. */ + countryCode: z.string(), + /** Dawarich's own spelling, kept so an unresolved country can still be named. */ + sourceName: z.string(), + cities: z.array( + z.object({ + name: z.string(), + /** How long the recordings put the user there, in minutes. */ + minutes: z.number(), + /** + * ISO-8601 of the most recent point in that city, or null when the + * instance sent none. Nullable rather than defaulted: a city stamped + * 1970-01-01 looks like data and is not. + */ + lastSeenAt: z.string().nullable(), + }), + ), + alreadyVisited: z.boolean(), +}); +export type DawarichAtlasCountry = z.infer; + +export const dawarichAtlasSuggestionsSchema = z.object({ + countries: z.array(dawarichAtlasCountrySchema), + /** Dawarich country names that matched no ISO code — named rather than dropped. */ + unresolved: z.array(z.string()), + fetchedAt: z.string(), +}); +export type DawarichAtlasSuggestions = z.infer; + +export const dawarichAtlasAcceptSchema = z.object({ + countryCodes: z.array(z.string().length(2)).min(1).max(300), +}); +export type DawarichAtlasAccept = z.infer; + +// ── Bucket list ────────────────────────────────────────────────────────────── + +/** + * Result of asking Dawarich whether the user ever reached a place on their + * bucket list. + * + * Proximity alone is not a visit — driving past a cathedral is not visiting it — + * so a match needs both a distance and a dwell time, and both are reported so + * the user can judge the call rather than trust it. + */ +export const dawarichBucketMatchSchema = z.object({ + itemId: idSchema, + name: z.string(), + /** Null when the recordings show nothing near it. */ + match: z + .object({ + /** ISO-8601 of the stay's start. */ + at: z.string(), + minutes: z.number(), + distanceMeters: z.number(), + /** How many recorded points backed the match. */ + points: z.number(), + }) + .nullable(), + /** Already ticked off before this scan ran. */ + alreadyVisited: z.boolean(), +}); +export type DawarichBucketMatch = z.infer; + +export const dawarichBucketScanSchema = z.object({ + matches: z.array(dawarichBucketMatchSchema), + /** Entries skipped because they carry no coordinates — nothing to match against. */ + skippedWithoutCoordinates: z.number(), + /** The scan stopped at the per-run cap; run it again for the rest. */ + truncated: z.boolean(), + fetchedAt: z.string(), +}); +export type DawarichBucketScan = z.infer; + +export const dawarichBucketConfirmSchema = z.object({ + itemIds: z.array(idSchema).min(1).max(200), + /** ISO-8601 the visit is recorded at. Defaults to the matched stay. */ + visitedAt: z.string().optional(), +}); +export type DawarichBucketConfirm = z.infer; + +// ── Tuning ─────────────────────────────────────────────────────────────────── + +/** + * What counts as being somewhere. + * + * These are TREK's numbers, not Dawarich's: Dawarich decides where a visit was, + * TREK decides whether that is enough to call a wish fulfilled. Kept next to the + * contract so the server, the tests and the help text cannot disagree. + */ +export const DAWARICH_BUCKET_MATCH_RADIUS_M = 250; +export const DAWARICH_BUCKET_MATCH_MIN_MINUTES = 20; +/** Bucket-list entries examined per scan — one upstream request each. */ +export const DAWARICH_BUCKET_SCAN_LIMIT = 50; +/** Points kept per day after thinning, so a month-long trip stays drawable. */ +export const DAWARICH_TRACK_POINTS_PER_DAY = 600; +/** How far back a sync looks for trips that have no end date yet. */ +export const DAWARICH_SYNC_LOOKBACK_DAYS = 3; +/** Days past a trip's end still worth polling — a stay can end after midnight. */ +export const DAWARICH_SYNC_LOOKAHEAD_DAYS = 1; diff --git a/shared/src/index.ts b/shared/src/index.ts index 89a3f15c8..8fd5ad281 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -33,6 +33,7 @@ export * from './reservation/reservation.schema'; export * from './reservation/ki-reservation.schema'; export * from './datetime/datetime-normalize'; export * from './airtrail/airtrail.schema'; +export * from './dawarich/dawarich.schema'; export * from './day/day.schema'; export * from './day/note-colors'; export * from './assignment/assignment.schema'; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 43db81962..e202daeb6 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -138,6 +138,12 @@ export const placeSchema = z.object({ // The Amap (高德) POI id a place was found by, `amap:`-prefixed. null for every // place that came from another provider. amap_poi_id: z.string().nullable().optional(), + /** + * Where the place came from when it was not typed by a person — currently + * only `'dawarich'`, for a stay accepted out of someone's own recordings. + * null on every place added by hand, which is nearly all of them. + */ + source: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(), // Manual track colour (#776). null = inherit the category colour like before. route_color: hexColorSchema.nullable().optional(),