mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
3c7f6e69dd
On a day that opens with a flight, where the traveller also put the hotel on the day as a stop of their own, the phone timeline started the day with a hotel row and the drive from that stop to the next place, above the flight, and then showed the same drive again in its own spot. hotelLegsForDay guessed the bookends from coordinates: the first segment leaving the hotel's spot was the morning leg. The route had correctly drawn no morning leg on that day, but the stop on the hotel's spot starts a leg there too, so that hop was taken for it. The route calculation now tags the two legs it draws from and to the accommodation, and the timeline reads that tag instead. The hotel end of a tagged leg still has to be the day's own stay, because the calculation keeps the previous day's legs until the new day has routed. The stop to stop connectors leave tagged legs alone, so one leg can no longer show up in both places.
565 lines
18 KiB
TypeScript
565 lines
18 KiB
TypeScript
// Shared types for the TREK travel planner.
|
||
//
|
||
// Domain entity/response types are now sourced from @trek/shared — the single
|
||
// source of truth shared with the server. The Zod schemas there are built to
|
||
// match the REAL server response shapes (see shared/src/<domain>/*.schema.ts,
|
||
// each documented against the producing service). Re-exported here so the rest
|
||
// of the client keeps importing from '../types' unchanged.
|
||
import type {
|
||
TrekWsEventName,
|
||
TrekWsPluginEventName,
|
||
Trip,
|
||
TripMember,
|
||
Day,
|
||
DayNote,
|
||
Place,
|
||
AssignmentPlace,
|
||
PlaceCategory,
|
||
Assignment,
|
||
AssignmentParticipant,
|
||
PackingItem,
|
||
PackingBag,
|
||
PackingBagMember,
|
||
BudgetItem,
|
||
BudgetItemMember,
|
||
BudgetItemReceipt,
|
||
Reservation,
|
||
ReservationEndpoint,
|
||
Accommodation,
|
||
Tag,
|
||
Category,
|
||
AppearanceConfig,
|
||
} from '@trek/shared'
|
||
|
||
export type {
|
||
Trip,
|
||
TripMember,
|
||
Day,
|
||
DayNote,
|
||
Place,
|
||
AssignmentPlace,
|
||
PlaceCategory,
|
||
Assignment,
|
||
AssignmentParticipant,
|
||
PackingItem,
|
||
PackingBag,
|
||
PackingBagMember,
|
||
BudgetItem,
|
||
BudgetItemMember,
|
||
BudgetItemReceipt,
|
||
Reservation,
|
||
ReservationEndpoint,
|
||
Accommodation,
|
||
Tag,
|
||
Category,
|
||
AppearanceConfig,
|
||
}
|
||
|
||
export interface User {
|
||
id: number
|
||
username: string
|
||
email: string
|
||
role: 'admin' | 'user'
|
||
avatar_url: string | null
|
||
maps_api_key: string | null
|
||
created_at: string
|
||
/** Present after load; true when TOTP MFA is enabled for password login */
|
||
mfa_enabled?: boolean
|
||
/** True when a password change is required before the user can continue */
|
||
must_change_password?: boolean
|
||
}
|
||
|
||
export interface TodoItem {
|
||
id: number
|
||
trip_id: number
|
||
name: string
|
||
category: string | null
|
||
checked: number
|
||
sort_order: number
|
||
due_date: string | null
|
||
description: string | null
|
||
assigned_user_id: number | null
|
||
priority: number
|
||
}
|
||
|
||
export interface TripFile {
|
||
id: number
|
||
trip_id: number
|
||
place_id?: number | null
|
||
reservation_id?: number | null
|
||
note_id?: number | null
|
||
uploaded_by?: number | null
|
||
uploaded_by_name?: string | null
|
||
uploaded_by_avatar?: string | null
|
||
filename: string
|
||
original_name: string
|
||
file_size?: number | null
|
||
mime_type: string
|
||
description?: string | null
|
||
starred?: number
|
||
deleted_at?: string | null
|
||
created_at: string
|
||
reservation_title?: string
|
||
linked_reservation_ids?: (number | null)[]
|
||
linked_place_ids?: (number | null)[]
|
||
/** Served download path — always present on list/create/update responses (formatFile). */
|
||
url: string
|
||
}
|
||
|
||
export type DistanceUnit = 'metric' | 'imperial'
|
||
|
||
export interface Settings {
|
||
map_tile_url: string
|
||
/**
|
||
* Base URL of a self-hosted routing engine (#1797). Empty falls back to the public
|
||
* FOSSGIS hosts, which allow about one request a second.
|
||
*/
|
||
routing_base_url?: string
|
||
/**
|
||
* Base URL of the Valhalla asked for the avoidance questions OSRM cannot answer.
|
||
* Undefined means the public FOSSGIS instance TREK ships with; empty means an
|
||
* operator turned the second engine off and "other ways" goes back to OSRM alone.
|
||
*/
|
||
valhalla_base_url?: string
|
||
dark_mode: boolean | string
|
||
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
|
||
default_currency: string | null
|
||
language: string
|
||
temperature_unit: string
|
||
distance_unit?: DistanceUnit
|
||
time_format: string
|
||
show_place_description: boolean
|
||
blur_booking_codes?: boolean
|
||
map_booking_labels?: boolean
|
||
map_poi_pill_enabled?: boolean
|
||
map_always_show_routes?: boolean
|
||
optimize_from_accommodation?: boolean
|
||
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
|
||
/** Leaflet base layer: default street tiles or a satellite/aerial view. */
|
||
map_base_layer?: 'default' | 'satellite'
|
||
/**
|
||
* The three road-trip driving limits (#1797). All three are personal rather than
|
||
* instance configuration — how long you are willing to drive and how far your car goes
|
||
* are properties of the traveller, so they are plain per-user settings and stay out of
|
||
* the defaultable list.
|
||
*
|
||
* Stored as numbers; 0 or absent means no limit. Kilometres are always kilometres in
|
||
* storage and converted for display, the same rule the corridor widths follow.
|
||
*/
|
||
roadtrip_leg_minutes?: number
|
||
roadtrip_day_minutes?: number
|
||
roadtrip_day_start?: string
|
||
roadtrip_day_end?: string
|
||
roadtrip_day_end_mode?: 'route' | 'stop'
|
||
roadtrip_range_km?: number
|
||
/**
|
||
* Road classes the drive should leave out where it can, as a comma list
|
||
* ("toll,ferry"). Absent means route normally, which is the right meaning for absent:
|
||
* the settings row does not exist until somebody turns one of them on, and the first
|
||
* paint of every session reads this before the settings have loaded.
|
||
*
|
||
* A list rather than three booleans because it is one decision with three parts, and
|
||
* because it goes to the router as one request either way.
|
||
*/
|
||
roadtrip_avoid?: string
|
||
/**
|
||
* What the traveller drives: 'combustion', 'electric', or absent for "did not say".
|
||
*
|
||
* Absent has to mean BOTH kinds refill, which is what the range budget did before this
|
||
* existed. Anything else would quietly change the warnings of every traveller who never
|
||
* opened the dialog.
|
||
*/
|
||
roadtrip_vehicle?: string
|
||
/** Route the gaps between days too, so the trip is one continuous drive. */
|
||
roadtrip_connect_days?: boolean
|
||
/** Draw each day of the trip in its own colour. */
|
||
roadtrip_day_colors?: boolean
|
||
/**
|
||
* How full a fill-up goes, 1 to 100. Absent or 100 means all the way.
|
||
*
|
||
* Nobody charges to 100 % on the road, so an electric traveller who leaves this at full
|
||
* gets a range figure a fifth too generous after every stop.
|
||
*/
|
||
roadtrip_fill_percent?: number
|
||
/**
|
||
* What the vehicle is made of, for travellers who would rather not do the division.
|
||
*
|
||
* Optional throughout, and stored METRIC throughout — litres and kilowatt-hours, per
|
||
* 100 kilometres — exactly as roadtrip_range_km is always kilometres. The dialog does
|
||
* the round trip into gallons, miles per gallon and kWh per 100 miles for an imperial
|
||
* traveller; storage stays one system so the arithmetic never has to ask.
|
||
*
|
||
* When a pair is complete it WINS over roadtrip_range_km, because it is the more
|
||
* specific answer. Half a pair computes nothing: a made-up range here would put the
|
||
* fuel warnings at the wrong place while looking exact.
|
||
*/
|
||
roadtrip_tank_litres?: number
|
||
roadtrip_litres_per_100?: number
|
||
roadtrip_battery_kwh?: number
|
||
roadtrip_kwh_per_100?: number
|
||
/**
|
||
* Percent of the battery the years have taken. Absent means none.
|
||
*
|
||
* The one figure of ABRP's list that changes the answer here. Plug type, reference
|
||
* speed and drive style shape a consumption PREDICTION; this addon does not predict
|
||
* one, it divides a capacity by a consumption the traveller states. Degradation is
|
||
* different in kind: it shrinks the capacity itself, and on a five-year-old car by
|
||
* enough to decide whether the last leg of a day arrives.
|
||
*/
|
||
roadtrip_battery_degradation?: number
|
||
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
|
||
carto_api_key?: string
|
||
mapbox_access_token?: string
|
||
mapbox_style?: string
|
||
maplibre_style?: string
|
||
mapbox_3d_enabled?: boolean
|
||
mapbox_quality_mode?: boolean
|
||
// Dashboard widget prefs — persisted server-side so a (docker) upgrade keeps them (#1311).
|
||
dashboard_fx_from?: string
|
||
dashboard_fx_to?: string
|
||
dashboard_timezones?: string[]
|
||
/** Where opening TREK lands: the dashboard, or straight in the active trip. */
|
||
start_page?: 'dashboard' | 'active_trip'
|
||
/** Which planner tab 'active_trip' opens on — a TripTabId (constants/tripTabs). */
|
||
start_trip_tab?: string
|
||
// AI booking-import fallback (per-user config; used when the admin has not set
|
||
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
|
||
llm_provider?: 'local' | 'openai' | 'anthropic'
|
||
llm_model?: string
|
||
llm_base_url?: string
|
||
llm_multimodal?: boolean
|
||
llm_api_key?: string
|
||
/** Per-user appearance/customization config (theming, transparency, typography, dashboard widgets). */
|
||
appearance?: AppearanceConfig
|
||
}
|
||
|
||
export interface AssignmentsMap {
|
||
[dayId: string]: Assignment[]
|
||
}
|
||
|
||
export interface DayNotesMap {
|
||
[dayId: string]: DayNote[]
|
||
}
|
||
|
||
export interface RouteSegment {
|
||
mid: [number, number]
|
||
from: [number, number]
|
||
to: [number, number]
|
||
distance: number
|
||
duration: number
|
||
walkingText: string
|
||
drivingText: string
|
||
distanceText: string
|
||
durationText?: string
|
||
/** Extra text a plugin route attached to this leg (e.g. "25 min charge"). */
|
||
noteText?: string
|
||
/** The travel mode this leg was routed with (#1281) — drives the connector icon. */
|
||
mode?: string
|
||
/**
|
||
* Set on the drive from the day's accommodation to its first stop ('morning') or
|
||
* from its last stop back to it ('evening'), so a list can show that leg at the
|
||
* day's edge without guessing it from coordinates a stop may share (#2501).
|
||
*/
|
||
hotelBookend?: 'morning' | 'evening'
|
||
}
|
||
|
||
/** An intermediate stop a plugin route places on the drawn line (charging stop, rest area). */
|
||
export interface RouteVia {
|
||
hoverCard?: boolean
|
||
nightPause?: { day: number; atPlace: boolean; position?: number; manual?: boolean; minPosition?: number; maxPosition?: number }
|
||
lat: number
|
||
lng: number
|
||
label?: string
|
||
tone: 'default' | 'success' | 'warn' | 'danger'
|
||
dwellSeconds?: number
|
||
}
|
||
|
||
/**
|
||
* Where the router put a waypoint we asked about, and how far that is from where we asked.
|
||
*
|
||
* Every routing engine snaps a coordinate to the nearest road before it starts, and OSRM
|
||
* does it with no distance limit at all. A place set back from the road — a viewpoint, a
|
||
* farmhouse, a marina — is therefore driven to from somewhere else entirely, and the drawn
|
||
* line starts at that somewhere else without saying so.
|
||
*/
|
||
export interface SnappedWaypoint {
|
||
/** The coordinate that was asked for, unchanged. */
|
||
asked: [number, number]
|
||
/** The point on the road network the router actually used. */
|
||
at: [number, number]
|
||
/** Straight-line metres between the two. */
|
||
meters: number
|
||
}
|
||
|
||
export interface RouteWithLegs {
|
||
coordinates: [number, number][]
|
||
distance: number
|
||
duration: number
|
||
legs: RouteSegment[]
|
||
/** Present on plugin-provided routes only. */
|
||
vias?: RouteVia[]
|
||
/** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */
|
||
snapped?: SnappedWaypoint[]
|
||
/**
|
||
* What was asked to be left out, and what the road actually left out.
|
||
*
|
||
* The two differ, and that is the point. Valhalla weights a class away rather than
|
||
* banning it, so a drive with no untolled connection comes back on a toll road and
|
||
* says so. Present only on a route that was asked to avoid something, so `undefined`
|
||
* means the question was never put rather than "avoided nothing".
|
||
*
|
||
* `fellBack` is set when the engine that weighs classes away could not answer and OSRM
|
||
* drove the request unweighted instead. Nothing was avoided then, so `achieved` is
|
||
* empty, the same reading the server's planner gives such a leg.
|
||
*/
|
||
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[]; fellBack?: boolean }
|
||
/** Whether the road crosses by ferry, where the engine that drew it says so. */
|
||
hasFerry?: boolean
|
||
}
|
||
|
||
/** A road class a route can be asked to leave out. */
|
||
export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'
|
||
|
||
export interface RouteResult {
|
||
coordinates: [number, number][]
|
||
distance: number
|
||
duration: number
|
||
distanceText: string
|
||
durationText: string
|
||
walkingText: string
|
||
drivingText: string
|
||
}
|
||
|
||
export interface Waypoint {
|
||
lat: number
|
||
lng: number
|
||
}
|
||
|
||
// Optional fixed start/end points for route optimization (e.g. the day's accommodation).
|
||
export interface RouteAnchors {
|
||
start?: Waypoint
|
||
end?: Waypoint
|
||
}
|
||
|
||
// User with optional OIDC fields
|
||
export interface UserWithOidc extends User {
|
||
oidc_issuer?: string | null
|
||
}
|
||
|
||
// Atlas place detail
|
||
export interface AtlasPlace {
|
||
id: number
|
||
name: string
|
||
lat: number | null
|
||
lng: number | null
|
||
}
|
||
|
||
// GeoJSON types (simplified for atlas map)
|
||
export interface GeoJsonFeature {
|
||
type: 'Feature'
|
||
properties: Record<string, string | number | null | undefined>
|
||
geometry: {
|
||
type: string
|
||
coordinates: unknown
|
||
}
|
||
id?: string
|
||
}
|
||
|
||
export interface GeoJsonFeatureCollection {
|
||
type: 'FeatureCollection'
|
||
features: GeoJsonFeature[]
|
||
}
|
||
|
||
// App config from /auth/app-config
|
||
export interface AppConfig {
|
||
has_users: boolean
|
||
allow_registration: boolean
|
||
/** True when the operator of this install owns its configuration, not the admin */
|
||
managed?: boolean
|
||
demo_mode: boolean
|
||
oidc_configured: boolean
|
||
oidc_display_name?: string
|
||
oidc_only_mode?: boolean
|
||
has_maps_key?: boolean
|
||
/** Amap (高德地图) key present for this caller — the alternative places provider. */
|
||
has_amap_key?: boolean
|
||
/** The admin's places provider choice: 'auto' | 'google' | 'amap' | 'openstreetmap'. */
|
||
places_provider?: string
|
||
allowed_file_types?: string
|
||
timezone?: string
|
||
/** When true, users without MFA cannot use the app until they enable it */
|
||
require_mfa?: boolean
|
||
// Granular auth toggles
|
||
password_login?: boolean
|
||
password_registration?: boolean
|
||
oidc_login?: boolean
|
||
oidc_registration?: boolean
|
||
env_override_oidc_only?: boolean
|
||
}
|
||
|
||
// Translation function type
|
||
export type TranslationFn = (key: string, params?: Record<string, string | number | null>) => string
|
||
|
||
// WebSocket event type — `type` is derived from the shared WS event registry
|
||
// (TREK_WS_EVENTS): a registered name, the reserved plugin namespace, or (via
|
||
// the `string & {}` widening) a transport control frame the registry
|
||
// deliberately excludes (welcome/joined/left/error). Payload fields stay
|
||
// index-typed at this boundary; per-event payload contracts live in
|
||
// TrekWsPayload<E> from @trek/shared.
|
||
export interface WebSocketEvent {
|
||
type: TrekWsEventName | TrekWsPluginEventName | (string & {})
|
||
[key: string]: unknown
|
||
}
|
||
|
||
// Vacay types
|
||
export interface VacayHolidayCalendar {
|
||
id: number
|
||
plan_id: number
|
||
type?: 'public_holiday' | 'school_holiday'
|
||
region: string
|
||
label: string | null
|
||
color: string
|
||
sort_order: number
|
||
}
|
||
|
||
export interface VacayPlan {
|
||
id: number
|
||
holidays_enabled: boolean
|
||
school_holidays_enabled?: boolean
|
||
holidays_region: string | null
|
||
holiday_calendars: VacayHolidayCalendar[]
|
||
block_weekends: boolean
|
||
carry_over_enabled: boolean
|
||
company_holidays_enabled: boolean
|
||
// Comma-separated weekday indices (e.g. '0,6'); stored as TEXT on vacay_plans.
|
||
weekend_days?: string
|
||
week_start?: number
|
||
name?: string
|
||
year?: number
|
||
owner_id?: number
|
||
created_at?: string
|
||
updated_at?: string
|
||
}
|
||
|
||
export interface VacayUser {
|
||
id: number
|
||
username: string
|
||
color: string | null
|
||
}
|
||
|
||
export interface VacayEntry {
|
||
date: string
|
||
user_id: number
|
||
plan_id?: number
|
||
person_color?: string
|
||
person_name?: string
|
||
// Portion of a vacation day this entry counts as: 1 = full day, 0.5 = half
|
||
// day (#552). Absent on legacy entries, which are treated as full days.
|
||
fraction?: number
|
||
// Leave type (#1074): 'comp' = flex/comp day (does not touch the entitlement),
|
||
// 'vacation' (or absent, for legacy entries) = a regular vacation day.
|
||
kind?: 'vacation' | 'comp'
|
||
}
|
||
|
||
// Vacay per-user stats row as returned by getStats
|
||
// (server/src/services/vacayService.ts -> getStats).
|
||
export interface VacayStat {
|
||
user_id: number
|
||
person_name: string
|
||
person_color: string
|
||
year: number
|
||
vacation_days: number
|
||
carried_over: number
|
||
total_available: number
|
||
used: number
|
||
remaining: number
|
||
// Comp/flex days used this year (#1074) — informational, not deducted from the
|
||
// entitlement. Absent on older server builds.
|
||
comp_used?: number
|
||
// The leave-year window this row was computed over (#737), as YYYY-MM-DD.
|
||
// `window_end` is exclusive. Absent on older server builds.
|
||
window_start?: string
|
||
window_end?: string
|
||
}
|
||
|
||
export type VacayYearType = 'calendar' | 'fiscal' | 'anniversary'
|
||
|
||
/**
|
||
* Per-user leave-year configuration (#737). 'calendar' is the unchanged Jan–Dec
|
||
* default, 'fiscal' starts on a fixed month/day, 'anniversary' on the month/day
|
||
* of the hire date.
|
||
*/
|
||
export interface VacayYearSettings {
|
||
year_type: VacayYearType
|
||
year_start_month: number
|
||
year_start_day: number
|
||
hire_date: string | null
|
||
}
|
||
|
||
export interface HolidayInfo {
|
||
name: string
|
||
localName: string
|
||
color: string
|
||
label: string | null
|
||
type?: 'public_holiday' | 'school_holiday'
|
||
}
|
||
|
||
export interface HolidaysMap {
|
||
[date: string]: HolidayInfo | HolidayInfo[]
|
||
}
|
||
|
||
// Read-only calendar shares (#444/#667)
|
||
export interface VacayShareOutgoing {
|
||
id: number
|
||
user_id: number
|
||
username: string
|
||
}
|
||
|
||
export interface VacayShareIncoming {
|
||
id: number
|
||
owner_id: number
|
||
username: string
|
||
color: string
|
||
hidden: boolean
|
||
}
|
||
|
||
export interface SharedVacayCalendar {
|
||
share_id: number
|
||
owner_id: number
|
||
owner_name: string
|
||
color: string
|
||
hidden: boolean
|
||
entries: { date: string; fraction?: number; kind?: 'vacation' | 'comp' }[]
|
||
companyHolidays: { date: string; note?: string }[]
|
||
}
|
||
|
||
// API error shape from axios
|
||
export interface ApiError {
|
||
response?: {
|
||
data?: {
|
||
error?: string
|
||
}
|
||
status?: number
|
||
}
|
||
message: string
|
||
}
|
||
|
||
/** Safely extract an error message from an unknown catch value */
|
||
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||
const apiErr = err as ApiError
|
||
// Axios' own message ("Request failed with status code 500") is untranslated
|
||
// boilerplate, so only the server's error text beats the localized fallback.
|
||
return apiErr.response?.data?.error || fallback
|
||
}
|
||
if (err instanceof Error) return err.message
|
||
return fallback
|
||
}
|
||
|
||
// MergedItem used in day notes hook
|
||
export interface MergedItem {
|
||
type: 'assignment' | 'note' | 'place' | 'transport'
|
||
sortKey: number
|
||
data: Assignment | DayNote | Reservation
|
||
}
|