Files
TREK/client/src/native/widgets.ts
T
Maurice fc648bc7e4 fix: green checks after the first-screen redesign and the safe-area floor
- native-shell/amount.ts: the no-break spaces in the price pattern were
  written out as characters; \s already matches them.
- widgets.ts: the Live Activity's lead time stays inside the module.
- i18n: the count strings of the new plan tiles replace the removed ones
  on NOT_PLURAL, and the address placeholders of the non-Latin locales
  are their own instead of English copies.
- mobile.css: the side safe-area margins of the floating bars use
  margin-inline, swapped for right to left, instead of physical sides.
2026-11-12 14:36:13 +01:00

407 lines
14 KiB
TypeScript

import type { Day, Reservation, Trip } from '../types';
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip';
import { hasNativeFeature } from './capabilities';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
import type { WidgetAtlas, WidgetCollab, WidgetPackingDetail } from './widgetOnline';
/**
* What the home-screen widgets and the travel-day Live Activity show, built from
* the trips this device keeps offline (db/offlineDb) and handed to the app
* (TrekShell.setWidgetSnapshot, syncTripDay). The texts go along translated, so
* the widgets speak the app's language without localising anything themselves.
* Everything past trip, today and the first six labels is optional on the Swift
* side (client/ios/App/TREKWidgets/WidgetSnapshot.swift), so an older app
* binary ignores it and a newer one copes without it.
*/
export interface WidgetLabels {
nextTrip: string;
today: string;
noTrip: string;
/** Contains "{n}". */
inDays: string;
tomorrow: string;
nothingPlanned: string;
days?: string;
bookings?: string;
noBookings?: string;
todos?: string;
noTodos?: string;
packing?: string;
confirmed?: string;
pending?: string;
todayShort?: string;
tomorrowShort?: string;
now?: string;
places?: string;
travellers?: string;
allDone?: string;
missing?: string;
countries?: string;
world?: string;
group?: string;
poll?: string;
votes?: string;
noMessages?: string;
departure?: string;
}
interface WidgetStop {
time: string | null;
name: string;
/** Minutes after midnight, for picking the next stop and the widget's now line. */
minutes: number | null;
color?: string | null;
symbol?: string | null;
/** Minutes after midnight it ends, past midnight as 1440: a flight in the air is not done yet. */
endMinutes?: number | null;
}
interface SnapshotStop {
time: string | null;
name: string;
minutes?: number;
color?: string;
symbol?: string;
}
export interface WidgetSnapshot {
trip: {
title: string;
start: string;
end: string;
path: string;
place?: string;
/** A small JPEG of the cover, base64. */
cover?: string;
dayCount?: number;
placeCount?: number;
memberCount?: number;
} | null;
today: { dayLabel: string; stops: SnapshotStop[]; index?: number; total?: number } | null;
weather?: WidgetWeather;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: WidgetPacking & Partial<WidgetPackingDetail>;
atlas?: WidgetAtlas;
collab?: WidgetCollab;
labels: WidgetLabels;
}
export interface WidgetWeather {
temp: number;
high?: number;
low?: number;
/** The provider's main condition ("Clear", "Rain", ...), which the widget turns into a symbol. */
main: string;
label: string;
place?: string;
/** Chance of rain in percent. */
rain?: number;
}
/** What goes along with the trip beyond its day; each part optional. */
interface SnapshotExtras {
place?: string | null;
cover?: string | null;
placeCount?: number;
memberCount?: number;
weather?: WidgetWeather | null;
bookings?: WidgetBooking[];
todos?: WidgetTodos;
packing?: (WidgetPacking & Partial<WidgetPackingDetail>) | null;
atlas?: WidgetAtlas | null;
collab?: WidgetCollab | null;
}
export interface TripDay {
tripTitle: string;
path: string;
dayLabel: string;
stops: WidgetStop[];
/** The day's id, which the legs of a multi-leg booking name. */
dayId?: number;
/** 1-based position of the day in the trip, and the trip's day count. */
index?: number;
total?: number;
}
/** iOS (WidgetKit) and Android (Glance) read the same snapshot. */
export function widgetsActive(): boolean {
return isNativeApp() && hasNativeFeature('widgets');
}
export function minutesOf(time: string | null | undefined): number | null {
const m = /^(\d{1,2}):(\d{2})/.exec(time ?? '');
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
}
/**
* The trip the dashboard's hero features (utils/heroTrip.ts): the running one,
* else the next to start, so the widgets show the trip the app opens on.
* Archived trips are not candidates, as on the dashboard.
*/
export function pickTrip(trips: Trip[], today: string): Trip | null {
const active = trips.filter((t) => !t.is_archived);
return heroTrip(sortTripsByRelevance(active, today), today);
}
/**
* The trips a widget can be set to in Edit Widget: the ones running or coming
* up, in the dashboard's order (so the hero's trip comes first), at most `limit`,
* each of which gets a snapshot of its own.
*/
export function widgetTripChoices(trips: Trip[], today: string, limit = 6): Trip[] {
const ahead = trips.filter((t) => !t.is_archived && t.start_date && (t.end_date ?? t.start_date) >= today);
return sortTripsByRelevance(ahead, today).slice(0, limit);
}
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
export const RESERVATION_SYMBOLS: Record<string, string> = {
flight: 'airplane',
train: 'tram.fill',
bus: 'bus.fill',
car: 'car.fill',
cruise: 'ferry.fill',
ferry: 'ferry.fill',
hotel: 'bed.double.fill',
restaurant: 'fork.knife',
event: 'ticket.fill',
tour: 'figure.hiking',
};
/** Puts each timed booking before the first stop that comes later; untimed stops keep their place. */
export function mergeByTime<T extends { minutes?: number | null }>(stops: T[], booked: T[]): T[] {
const out = [...stops];
for (const b of [...booked].sort((x, y) => (x.minutes ?? 0) - (y.minutes ?? 0))) {
const at = out.findIndex((s) => s.minutes != null && s.minutes > (b.minutes ?? 0));
if (at < 0) out.push(b);
else out.splice(at, 0, b);
}
return out;
}
/** Today's day of the trip with its stops in plan order, if the trip runs today. */
export function todayOf(
trip: Trip,
days: Day[],
today: string,
dayLabel: (index: number, total: number) => string,
formatTime: (time: string) => string
): TripDay | null {
const ordered = days.filter((d) => d.trip_id === trip.id).sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0));
const index = ordered.findIndex((d) => (d.date ?? '').slice(0, 10) === today);
if (index < 0) return null;
const day = ordered[index];
const stops = [...(day.assignments ?? [])]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.flatMap((a): WidgetStop[] =>
a.place?.name
? [
{
name: a.place.name,
time: a.place.place_time ? formatTime(a.place.place_time.slice(0, 5)) : null,
minutes: minutesOf(a.place.place_time),
...stopLook(a.place.category),
},
]
: []
);
return {
tripTitle: trip.title,
path: `/trips/${trip.id}`,
dayLabel: dayLabel(index + 1, ordered.length),
stops,
dayId: day.id,
index: index + 1,
total: ordered.length,
};
}
/** The day with its timed bookings (a flight, the table tonight) joined to the stops in time order. */
export function withBookings(
day: TripDay,
reservations: Reservation[],
today: string,
formatTime: (time: string) => string
): TripDay {
const booked = reservations.flatMap((r): WidgetStop[] => {
if (r.status === 'cancelled') return [];
const symbol = RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill';
// A booking with stopovers is a stop per leg, as the planner shows it: one
// stop at the first departure counted the day done while legs were ahead.
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : [];
if (legs.length > 1 && day.dayId != null && legs.every((l) => l.dep_day_id != null)) {
return legs.flatMap((leg): WidgetStop[] => {
if (leg.dep_day_id !== day.dayId || !leg.dep_time) return [];
return [
{
name: leg.from && leg.to ? `${leg.from} → ${leg.to}` : r.title,
time: formatTime(leg.dep_time),
minutes: minutesOf(leg.dep_time),
// A leg without an arrival day lands on the day it leaves, as the planner reads it.
endMinutes: (leg.arr_day_id ?? leg.dep_day_id) === day.dayId ? minutesOf(leg.arr_time) : 24 * 60,
color: null,
symbol,
},
];
});
}
const { date, time } = splitReservationDateTime(r.reservation_time);
if (!time || date !== today) return [];
const end = splitReservationDateTime(r.reservation_end_time);
return [
{
name: r.title,
time: formatTime(time),
minutes: minutesOf(time),
endMinutes: !end.time ? null : end.date && end.date !== today ? 24 * 60 : minutesOf(end.time),
color: null,
symbol,
},
];
});
return booked.length ? { ...day, stops: mergeByTime(day.stops, booked) } : day;
}
/** A stop as the widget gets it: the empty fields left out rather than sent as null. */
function snapshotStop({ time, name, minutes, color, symbol }: WidgetStop): SnapshotStop {
return {
time,
name,
...(minutes != null ? { minutes } : {}),
...(color ? { color } : {}),
...(symbol ? { symbol } : {}),
};
}
export function buildSnapshot(
trip: Trip | null,
day: TripDay | null,
labels: WidgetLabels,
extras: SnapshotExtras = {}
): WidgetSnapshot {
const snapshot: WidgetSnapshot = {
trip: trip
? {
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
path: `/trips/${trip.id}`,
...(extras.place ? { place: extras.place } : {}),
...(extras.cover ? { cover: extras.cover } : {}),
...(trip.day_count ? { dayCount: trip.day_count } : {}),
...(extras.placeCount ? { placeCount: extras.placeCount } : {}),
...(extras.memberCount ? { memberCount: extras.memberCount } : {}),
}
: null,
today: day
? {
dayLabel: day.dayLabel,
stops: day.stops.map(snapshotStop),
...(day.index ? { index: day.index, total: day.total } : {}),
}
: null,
labels,
};
if (extras.weather) snapshot.weather = extras.weather;
if (extras.bookings) snapshot.bookings = extras.bookings;
if (extras.todos) snapshot.todos = extras.todos;
if (extras.packing) snapshot.packing = extras.packing;
if (extras.atlas) snapshot.atlas = extras.atlas;
if (extras.collab) snapshot.collab = extras.collab;
return snapshot;
}
/** What the Live Activity (iOS) and the Live Update (Android) show of the travel day. */
interface TripDayState {
tripTitle: string;
path: string;
dayLabel: string;
nextName: string;
nextTime?: string;
/** Android: minutes after midnight of the next stop, for the countdown. */
nextMinutes?: number;
/** Android: the colour of each timed stop, for the progress segments. */
colors?: (string | null)[];
done: number;
total: number;
}
/**
* The Live Activity's state: the first stop still ahead or still under way (a
* flight in the air until it lands), else the last.
*/
export function tripDayState(day: TripDay, nowMinutes: number, allDone: string): TripDayState {
const timed = day.stops.filter((s) => s.minutes != null);
const nextIndex = day.stops.findIndex(
(s) => s.minutes != null && (s.minutes >= nowMinutes || (s.endMinutes != null && s.endMinutes > nowMinutes))
);
const done = nextIndex < 0 ? timed.length : day.stops.slice(0, nextIndex).filter((s) => s.minutes != null).length;
const next = nextIndex >= 0 ? day.stops[nextIndex] : null;
return {
tripTitle: day.tripTitle,
path: day.path,
dayLabel: day.dayLabel,
nextName: next?.name ?? allDone,
nextTime: next?.time ?? undefined,
// Android's Live Update counts down to the next stop and colours a segment per stop.
nextMinutes: next?.minutes ?? undefined,
colors: day.stops.filter((s) => s.minutes != null).map((s) => s.color ?? null),
done,
total: timed.length,
};
}
/** How long before the day's first timed stop its Live Activity appears. */
const LIVE_LEAD_MINUTES = 3 * 60;
/**
* Whether the travel day's Live Activity belongs on the lock screen now: from
* three hours before the first stop with a time, then until the day is done. A
* day whose first stop is an evening flight does not hold the lock screen from
* the morning on.
*/
export function liveDayDue(day: TripDay, nowMinutes: number): boolean {
const first = day.stops.reduce<number | null>(
(min, s) => (s.minutes != null && (min === null || s.minutes < min) ? s.minutes : min),
null
);
return first !== null && nowMinutes >= first - LIVE_LEAD_MINUTES;
}
/** A trip a widget can be set to in Edit Widget, and its snapshot. */
export interface TripWidgetSnapshot {
id: number;
title: string;
start: string;
end: string;
snapshot: WidgetSnapshot;
}
export async function publishWidgets(
snapshot: WidgetSnapshot,
liveDay: TripDayState | null,
trips: TripWidgetSnapshot[] = []
): Promise<void> {
if (!widgetsActive()) return;
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(snapshot) }).catch(reportNativeFailure);
// An app binary without widgetTrips would take every one of these for the dashboard's snapshot.
if (hasNativeFeature('widgetTrips')) {
for (const trip of trips) {
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(trip.snapshot), key: `trip.${trip.id}` }).catch(
reportNativeFailure
);
}
const index = trips.map(({ id, title, start, end }) => ({ id, title, start, end }));
await TrekShell.setWidgetSnapshot({ json: JSON.stringify(index), key: 'trips' }).catch(reportNativeFailure);
}
if (!hasNativeFeature('liveActivity')) return;
if (liveDay && liveDay.total > 0) await TrekShell.syncTripDay(liveDay).catch(reportNativeFailure);
else await TrekShell.endTripDay().catch(reportNativeFailure);
}