feat(native): home and lock screen widgets in the TREK look

Four widgets instead of one plain text block, drawn in the phone tokens
(warm paper by day, near black by night) with contour lines and a route arc
as the travel motif:

- Trip (small, medium, large, lock screen): the cover photo, a countdown ring
  and a plane on its arc before the trip; the day of the trip as segments,
  the weather and the next stops on frosted glass while it runs
- Today (medium, large): the day as a timeline in its category colours, with
  a now line, the day's progress and the weather
- Bookings (small, medium, large, lock screen): flights, trains, hotels and
  tables ahead as coloured tiles, the next one featured, status dots
- To-dos (small, medium, large, lock screen): to-dos and packing as nested
  rings, the most pressing open to-dos with priority and due dates

The app now sends the destination, a small cover JPEG, the weather where the
trip is, the bookings ahead, the open to-dos and the packing count; every
new field is optional on both sides, so older binaries and servers keep
working. Timelines step every quarter hour so the now line moves without
the app. client/ios/WidgetPreviews renders every widget in every size, light
and dark, on the CI's Mac, uploaded as the widget-previews artifact.
This commit is contained in:
Maurice
2026-11-06 19:01:08 +01:00
parent 3466556849
commit b1037dec62
48 changed files with 3033 additions and 127 deletions
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import type { PackingItem, Reservation, TodoItem } from '../types';
import { bookingsAhead, packingSummary, stopLook, temperature, todosSummary, tripPlace } from './widgetExtras';
const reservation = (over: Partial<Reservation>): Reservation =>
({ id: 1, trip_id: 1, title: 'Booking', status: 'confirmed', type: 'other', ...over }) as Reservation;
const todo = (over: Partial<TodoItem>): TodoItem =>
({
id: 1,
trip_id: 1,
name: 'Task',
category: null,
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
...over,
}) as TodoItem;
describe('bookingsAhead', () => {
const days = new Map([[7, '2026-11-09']]);
const time = (t: string) => `T${t}`;
it('lists the bookings from today on, soonest first, with their kind, legs and status', () => {
const list = bookingsAhead(
[
reservation({ id: 1, title: 'Old', reservation_time: '2026-11-01T10:00' }),
reservation({
id: 2,
title: 'Dinner',
type: 'restaurant',
reservation_time: '2026-11-08T19:30',
status: 'pending',
location: 'Pontocho',
}),
reservation({
id: 3,
title: 'LH 716',
type: 'flight',
reservation_time: '2026-11-08T13:05',
endpoints: [
{
role: 'to',
sequence: 1,
name: 'Tokyo Haneda',
code: 'HND',
lat: 0,
lng: 0,
timezone: null,
local_time: null,
local_date: null,
},
{
role: 'from',
sequence: 0,
name: 'Frankfurt',
code: 'FRA',
lat: 0,
lng: 0,
timezone: null,
local_time: null,
local_date: null,
},
],
}),
reservation({ id: 4, title: 'Museum', type: 'activity', day_id: 7 }),
reservation({ id: 5, title: 'Undated' }),
reservation({ id: 6, title: 'Cancelled', reservation_time: '2026-11-08T08:00', status: 'cancelled' }),
reservation({ id: 7, title: 'Staged', reservation_time: '2026-11-08T08:00', ingest_state: 'staged' }),
],
{ dayDates: days, today: '2026-11-06', tripPath: '/trips/1', formatTime: time }
);
expect(list.map((b) => b.title)).toEqual(['LH 716', 'Dinner', 'Museum', 'Undated']);
expect(list[0]).toEqual({
title: 'LH 716',
kind: 'flight',
date: '2026-11-08',
time: 'T13:05',
detail: 'FRA → HND',
confirmed: true,
path: '/trips/1?tab=transports',
});
expect(list[1]).toMatchObject({
kind: 'restaurant',
detail: 'Pontocho',
confirmed: false,
path: '/trips/1?tab=buchungen',
});
expect(list[2]).toMatchObject({ kind: 'tour', date: '2026-11-09', time: null });
expect(list[3]).toMatchObject({ kind: 'other', date: null });
});
it('stops at the limit', () => {
const many = Array.from({ length: 8 }, (_, i) => reservation({ id: i, reservation_time: `2026-11-1${i}T10:00` }));
expect(
bookingsAhead(many, { dayDates: new Map(), today: '2026-11-06', tripPath: '/trips/1', formatTime: (t) => t })
).toHaveLength(5);
});
});
describe('todosSummary', () => {
it('counts the open ones and orders them by due date, then priority', () => {
const summary = todosSummary(
[
todo({ id: 1, name: 'Done', checked: 1 }),
todo({ id: 2, name: 'Someday', priority: 3 }),
todo({ id: 3, name: 'Soon', due_date: '2026-11-08' }),
todo({ id: 4, name: 'First', due_date: '2026-11-07T00:00:00' }),
todo({ id: 5, name: 'Low', priority: 1 }),
],
'/trips/1?tab=listen'
);
expect(summary.open).toBe(4);
expect(summary.total).toBe(5);
expect(summary.items.map((i) => i.name)).toEqual(['First', 'Soon', 'Someday', 'Low']);
expect(summary.items[0]).toEqual({ name: 'First', due: '2026-11-07', priority: 0 });
});
});
describe('packingSummary', () => {
it('counts packed against all, and is null without a list', () => {
const items = [{ checked: 1 }, { checked: 0 }, { checked: 1 }] as PackingItem[];
expect(packingSummary(items, '/p')).toEqual({ packed: 2, total: 3, path: '/p' });
expect(packingSummary([], '/p')).toBeNull();
});
});
describe('tripPlace', () => {
it('names the town and country most places share, without postcodes', () => {
expect(
tripPlace([
{ address: '68 Fukakusa Yabunouchicho, Fushimi Ward, 612-0882 Kyoto, Japan' },
{ address: 'Nakagyo Ward, 604-8054 Kyoto, Japan' },
{ address: '1-1 Chiyoda, 100-8111 Tokyo, Japan' },
{ address: null },
{ address: 'Nowhere' },
])
).toBe('Kyoto, Japan');
});
it('is null without usable addresses', () => {
expect(tripPlace([{ address: 'Main Street' }, { address: '' }])).toBeNull();
});
});
describe('stopLook and temperature', () => {
it('maps a category to a colour and an SF Symbol, ignoring what it cannot use', () => {
expect(stopLook({ color: '#E8A13A', icon: 'Coffee' })).toEqual({ color: '#E8A13A', symbol: 'cup.and.saucer.fill' });
expect(stopLook({ color: 'red', icon: 'Unknown' })).toEqual({ color: null, symbol: null });
expect(stopLook(null)).toEqual({ color: null, symbol: null });
});
it('converts to Fahrenheit only when asked, rounded', () => {
expect(temperature(18.4, 'celsius')).toBe(18);
expect(temperature(20, 'fahrenheit')).toBe(68);
expect(temperature(-0.6, undefined)).toBe(-1);
});
});
+238
View File
@@ -0,0 +1,238 @@
import type { PackingItem, Place, Reservation, TodoItem } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
/**
* The parts of the widget snapshot beyond the trip and today's stops: the
* bookings ahead, the to-dos, the packing list, the destination's name, and
* how a stop looks (its category's colour and an SF Symbol for its icon).
* Pure, so the shapes the widgets get are tested here; the Swift side is in
* client/ios/App/TREKWidgets/WidgetSnapshot.swift.
*/
export interface WidgetBooking {
title: string;
kind: string;
date: string | null;
time: string | null;
detail: string | null;
confirmed: boolean;
path: string;
}
export interface WidgetTodos {
open: number;
total: number;
items: { name: string; due: string | null; priority: number }[];
path: string;
}
export interface WidgetPacking {
packed: number;
total: number;
path: string;
}
/** The lucide icon names categories use (components/shared/categoryIcons.ts), as SF Symbols. */
const SYMBOLS: Record<string, string> = {
MapPin: 'mappin',
Building2: 'building.2.fill',
BedDouble: 'bed.double.fill',
UtensilsCrossed: 'fork.knife',
Utensils: 'fork.knife',
Landmark: 'building.columns.fill',
ShoppingBag: 'bag.fill',
Store: 'storefront.fill',
Bus: 'bus.fill',
Train: 'tram.fill',
Car: 'car.fill',
Plane: 'airplane',
Ship: 'ferry.fill',
Bike: 'bicycle',
Activity: 'figure.run',
Dumbbell: 'dumbbell.fill',
Mountain: 'mountain.2.fill',
Tent: 'tent.fill',
Anchor: 'sailboat.fill',
Coffee: 'cup.and.saucer.fill',
Beer: 'mug.fill',
Wine: 'wineglass.fill',
Camera: 'camera.fill',
Music: 'music.note',
Theater: 'theatermasks.fill',
Ticket: 'ticket.fill',
TreePine: 'tree.fill',
Waves: 'water.waves',
Leaf: 'leaf.fill',
Flower2: 'camera.macro',
Sun: 'sun.max.fill',
Globe: 'globe.europe.africa.fill',
Compass: 'safari.fill',
Flag: 'flag.fill',
Navigation: 'location.north.fill',
Map: 'map.fill',
Church: 'building.columns.fill',
Library: 'books.vertical.fill',
Home: 'house.fill',
Cross: 'cross.case.fill',
Heart: 'heart.fill',
Star: 'star.fill',
CreditCard: 'creditcard.fill',
Wifi: 'wifi',
Luggage: 'suitcase.rolling.fill',
Backpack: 'backpack.fill',
Zap: 'bolt.fill',
};
const HEX = /^#[0-9a-f]{6}$/i;
/** A stop's colour and symbol from its place's category; nulls when it has none. */
export function stopLook(category: { color?: string | null; icon?: string | null } | null | undefined): {
color: string | null;
symbol: string | null;
} {
return {
color: category?.color && HEX.test(category.color) ? category.color : null,
symbol: (category?.icon && SYMBOLS[category.icon]) || null,
};
}
/** The booking kinds the widget draws; the rest of RESERVATION_TYPES fold into these. */
const KINDS: Record<string, string> = {
flight: 'flight',
train: 'train',
transit: 'train',
cable_car: 'train',
bus: 'bus',
car: 'car',
taxi: 'car',
bicycle: 'car',
cruise: 'cruise',
ferry: 'cruise',
hotel: 'hotel',
restaurant: 'restaurant',
event: 'event',
tour: 'tour',
activity: 'tour',
};
function bookingDetail(r: Reservation): string | null {
const legs = [...(r.endpoints ?? [])].sort((a, b) => a.sequence - b.sequence);
const from = legs.find((e) => e.role === 'from');
const to = [...legs].reverse().find((e) => e.role === 'to');
if (from && to) return `${from.code || from.name} → ${to.code || to.name}`;
return r.location?.trim() || r.accommodation_name || r.place_name || null;
}
/** Transport bookings open on the trip's transports tab, the rest on its bookings tab. */
const TRANSPORT_KINDS = new Set(['flight', 'train', 'bus', 'car', 'cruise']);
/**
* The trip's bookings from today on, soonest first: those without a date at
* the end, cancelled ones and drafts an import still waits on left out.
*/
export function bookingsAhead(
reservations: Reservation[],
{
dayDates,
today,
tripPath,
formatTime,
limit = 5,
}: {
/** The trip's day ids to their dates, for bookings placed on a day without a date of their own. */
dayDates: Map<number, string>;
today: string;
tripPath: string;
formatTime: (time: string) => string;
limit?: number;
}
): WidgetBooking[] {
return reservations
.filter((r) => r.status !== 'cancelled' && (r.ingest_state ?? 'live') === 'live')
.map((r) => {
const { date, time } = splitReservationDateTime(r.reservation_time);
const day = date ?? (r.day_id != null ? (dayDates.get(r.day_id) ?? null) : null);
return { r, day, time };
})
.filter(({ day }) => day == null || day >= today)
.sort((a, b) => {
if (a.day !== b.day) return a.day == null ? 1 : b.day == null ? -1 : a.day.localeCompare(b.day);
return (a.time ?? '99:99').localeCompare(b.time ?? '99:99');
})
.slice(0, limit)
.map(({ r, day, time }) => {
const kind = KINDS[r.type] ?? 'other';
return {
title: r.title,
kind,
date: day,
time: time ? formatTime(time) : null,
detail: bookingDetail(r),
confirmed: r.status === 'confirmed',
path: `${tripPath}?tab=${TRANSPORT_KINDS.has(kind) ? 'transports' : 'buchungen'}`,
};
});
}
/** Open to-dos first by due date (undated last), then by priority, highest first. */
export function todosSummary(items: TodoItem[], path: string, limit = 6): WidgetTodos {
const open = items.filter((i) => !i.checked);
const sorted = [...open].sort((a, b) => {
if (a.due_date !== b.due_date) {
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date.localeCompare(b.due_date);
}
return (b.priority ?? 0) - (a.priority ?? 0) || a.sort_order - b.sort_order;
});
return {
open: open.length,
total: items.length,
items: sorted.slice(0, limit).map((i) => ({
name: i.name,
due: i.due_date ? i.due_date.slice(0, 10) : null,
priority: Math.max(0, Math.min(3, i.priority ?? 0)),
})),
path,
};
}
/** Packed items against all of them; null without a packing list. */
export function packingSummary(items: PackingItem[], path: string): WidgetPacking | null {
if (items.length === 0) return null;
return { packed: items.filter((i) => i.checked).length, total: items.length, path };
}
/**
* Where the trip goes, in a few words: the town and country most of its
* places share, read off their addresses ("…, 600-8216 Kyoto, Japan").
*/
export function tripPlace(places: Pick<Place, 'address'>[]): string | null {
const counts = new Map<string, number>();
for (const { address } of places) {
const parts = (address ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length < 2) continue;
const country = parts[parts.length - 1];
const town = parts[parts.length - 2].replace(/\b[\dA-Z]{0,4}[\d][\d\s-]*\b/g, '').trim();
if (!town || /\d/.test(country)) continue;
const key = `${town}, ${country}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let best: string | null = null;
let most = 0;
for (const [key, count] of counts) {
if (count > most) {
best = key;
most = count;
}
}
return best;
}
/** Celsius to the user's unit, rounded. */
export function temperature(celsius: number, unit: string | undefined): number {
return Math.round(unit === 'fahrenheit' ? (celsius * 9) / 5 + 32 : celsius);
}
+99
View File
@@ -0,0 +1,99 @@
import { fetchWeather } from '../services/weatherQueue';
import { temperature } from './widgetExtras';
import type { WidgetWeather } from './widgets';
/**
* The two things the widget snapshot needs from the network: the weather where
* the trip is, and the trip's cover as a small JPEG the widget can draw without
* a session of its own. Both are best effort: offline or failing, the widget
* simply goes without them, and both are kept so a quarter-hourly sync does not
* fetch them again.
*/
const WEATHER_TTL_MS = 60 * 60 * 1000;
const weatherCache = new Map<string, { at: number; value: WidgetWeather | null }>();
export async function widgetWeather(
{ lat, lng }: { lat: number; lng: number },
{ date, lang, unit, place }: { date: string; lang: string; unit: string | undefined; place?: string | null }
): Promise<WidgetWeather | null> {
const key = `${lat.toFixed(2)},${lng.toFixed(2)},${date},${lang},${unit ?? ''}`;
const hit = weatherCache.get(key);
if (hit && Date.now() - hit.at < WEATHER_TTL_MS) return hit.value;
let value: WidgetWeather | null = null;
try {
const w = await fetchWeather(lat, lng, date, lang);
if (!w.error && Number.isFinite(w.temp)) {
value = {
temp: temperature(w.temp, unit),
...(w.temp_max != null ? { high: temperature(w.temp_max, unit) } : {}),
...(w.temp_min != null ? { low: temperature(w.temp_min, unit) } : {}),
main: w.main,
label: w.description,
...(place ? { place } : {}),
...(w.precipitation_probability_max != null ? { rain: Math.round(w.precipitation_probability_max) } : {}),
};
}
} catch {
// Offline or the provider failed: the widget shows no weather until the next sync.
value = null;
}
weatherCache.set(key, { at: Date.now(), value });
return value;
}
const COVER_KEY = 'trek.widgetCover';
const COVER_MAX_PX = 720;
const COVER_MAX_BYTES = 15 * 1024 * 1024;
/** Kept under this so the keychain item the widget reads stays small. */
const COVER_MAX_BASE64 = 160_000;
function storedCover(url: string): string | null {
try {
const raw = localStorage.getItem(COVER_KEY);
const parsed = raw ? (JSON.parse(raw) as { url?: string; data?: string }) : null;
return parsed?.url === url && parsed.data ? parsed.data : null;
} catch {
return null;
}
}
function storeCover(url: string, data: string): void {
try {
localStorage.setItem(COVER_KEY, JSON.stringify({ url, data }));
} catch {
// Storage full: the cover is fetched again next time, nothing else depends on it.
}
}
/**
* The cover at `url` scaled to fit COVER_MAX_PX, as base64 JPEG without the
* data: prefix. Null without a cover, offline, or for anything not an image.
*/
export async function widgetCover(url: string | null | undefined): Promise<string | null> {
if (!url) return null;
const stored = storedCover(url);
if (stored) return stored;
try {
const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(10_000) });
const length = Number(res.headers.get('content-length') ?? 0);
if (!res.ok || length > COVER_MAX_BYTES) return null;
const blob = await res.blob();
if (!blob.type.startsWith('image/') || blob.size > COVER_MAX_BYTES) return null;
const bitmap = await createImageBitmap(blob);
const scale = Math.min(1, COVER_MAX_PX / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
let data = canvas.toDataURL('image/jpeg', 0.74).split(',')[1] ?? '';
if (data.length > COVER_MAX_BASE64) data = canvas.toDataURL('image/jpeg', 0.5).split(',')[1] ?? '';
if (!data || data.length > COVER_MAX_BASE64) return null;
storeCover(url, data);
return data;
} catch {
// Offline, a timeout or an image the WebView cannot decode: no cover this time.
return null;
}
}
+83 -1
View File
@@ -13,7 +13,7 @@ vi.mock('./trekShell', () => ({
}));
import { TrekShell } from './trekShell';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState } from './widgets';
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, withBookings } from './widgets';
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
@@ -129,3 +129,85 @@ describe('publishWidgets', () => {
expect(TrekShell.syncTripDay).not.toHaveBeenCalled();
});
});
describe('the richer snapshot', () => {
it('puts the timed bookings of the day between the stops and gives the day its place in the trip', () => {
const days = [
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
day(2, 2, '2026-11-07', []),
];
const plain = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const today = withBookings(
plain,
[
{ id: 1, trip_id: 1, title: 'Train', type: 'train', status: 'confirmed', reservation_time: '2026-11-06T12:15' },
{
id: 2,
trip_id: 1,
title: 'Tomorrow',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-11-07T08:00',
},
{ id: 3, trip_id: 1, title: 'Gone', type: 'event', status: 'cancelled', reservation_time: '2026-11-06T10:00' },
] as never,
'2026-11-06',
(t) => t
);
expect(today.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Train', 'Dinner']);
expect(today.stops[2]).toMatchObject({ time: '12:15', minutes: 735, symbol: 'tram.fill' });
expect(today).toMatchObject({ index: 1, total: 2 });
expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain);
});
it('carries the extras and leaves out what is empty', () => {
const snapshot = buildSnapshot(
{ ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip,
{
tripTitle: 'Trip 1',
path: '/trips/1',
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
},
labels,
{
place: 'Kyoto, Japan',
cover: null,
placeCount: 12,
memberCount: 0,
weather: { temp: 18, main: 'Clear', label: 'Sunny' },
bookings: [],
todos: { open: 1, total: 2, items: [], path: '/trips/1?tab=listen' },
packing: null,
}
);
expect(snapshot.trip).toEqual({
title: 'Trip 1',
start: '2026-11-06',
end: '2026-11-07',
path: '/trips/1',
place: 'Kyoto, Japan',
dayCount: 2,
placeCount: 12,
});
expect(snapshot.today).toEqual({
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
});
expect(snapshot.weather).toEqual({ temp: 18, main: 'Clear', label: 'Sunny' });
expect(snapshot.bookings).toEqual([]);
expect(snapshot.todos?.open).toBe(1);
expect(snapshot).not.toHaveProperty('packing');
});
});
+175 -11
View File
@@ -1,14 +1,19 @@
import type { Day, Trip } from '../types';
import type { Day, Reservation, Trip } from '../types';
import { splitReservationDateTime } from '../utils/formatters';
import { hasNativeFeature } from './capabilities';
import { isNativeIos } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
import { stopLook, type WidgetBooking, type WidgetPacking, type WidgetTodos } from './widgetExtras';
/**
* What the home-screen widget and the travel-day Live Activity show, built from
* 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 widget speaks the app's language without localising anything itself.
* 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;
@@ -18,26 +23,92 @@ export interface WidgetLabels {
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;
}
interface WidgetStop {
time: string | null;
name: string;
/** Minutes after midnight, for picking the next stop; not sent to the widget. */
/** Minutes after midnight, for picking the next stop and the widget's now line. */
minutes: number | null;
color?: string | null;
symbol?: string | 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 } | null;
today: { dayLabel: string; stops: { time: string | null; name: string }[] } | null;
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;
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 | null;
}
export interface TripDay {
tripTitle: string;
path: string;
dayLabel: string;
stops: WidgetStop[];
/** 1-based position of the day in the trip, and the trip's day count. */
index?: number;
total?: number;
}
export function widgetsActive(): boolean {
@@ -63,6 +134,31 @@ export function pickTrip(trips: Trip[], today: string): Trip | null {
return upcoming[0]?.trip ?? null;
}
/** The symbols of the booking kinds a day's timeline shows next to its stops. */
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. */
function mergeByTime(stops: WidgetStop[], booked: WidgetStop[]): WidgetStop[] {
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,
@@ -77,28 +173,96 @@ export function todayOf(
const day = ordered[index];
const stops = [...(day.assignments ?? [])]
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.flatMap((a) =>
.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 };
return {
tripTitle: trip.title,
path: `/trips/${trip.id}`,
dayLabel: dayLabel(index + 1, ordered.length),
stops,
index: index + 1,
total: ordered.length,
};
}
export function buildSnapshot(trip: Trip | null, day: TripDay | null, labels: WidgetLabels): WidgetSnapshot {
/** 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[] => {
const { date, time } = splitReservationDateTime(r.reservation_time);
if (r.status === 'cancelled' || !time || date !== today) return [];
return [
{
name: r.title,
time: formatTime(time),
minutes: minutesOf(time),
color: null,
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
},
];
});
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}` }
? {
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,
today: day ? { dayLabel: day.dayLabel, stops: day.stops.map(({ time, name }) => ({ time, name })) } : 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;
return snapshot;
}
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */