Files
TREK/client/src/types.ts
T
Maurice 3c7f6e69dd fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
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.
2026-09-26 04:22:50 +00:00

565 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}