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
+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);
}