Files
TREK/client/src/components/Layout/NativeWidgetsSync.tsx
T
Maurice 9755330113 feat(native): build the Apple Watch snapshot and take back what the watch ticks off
The hero trip goes to the watch in full: every day with its stops and
coordinates, the bookings with reference, seat, platform and both ends,
the packing list and to-dos with ids. Ticks from the watch come back
through TrekShell.takeWatchActions and are written through the trip
store's toggles, so they take the repos and the mutation queue like a tap
in the app.
2026-11-07 17:14:09 +01:00

242 lines
10 KiB
TypeScript

/**
* NativeWidgetsSync: keeps the iOS widgets and the travel-day Live Activity in
* step with the trips stored on this device (native/widgets.ts). Runs at start,
* whenever the app comes back to the foreground and every quarter hour while it
* is open, so the next stop moves on through the day. The paired Apple Watch
* gets the hero trip in full alongside (native/watch.ts), and what is ticked
* off on the watch comes back here and goes through the trip store's toggles.
* Renders nothing; outside an iOS binary with the widgets it does nothing.
*/
import { App } from '@capacitor/app';
import { useEffect, useRef } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { TrekShell } from '../../native/trekShell';
import { applyWatchActions, buildWatchSnapshot, publishWatch, takeWatchActions, watchActive } from '../../native/watch';
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
import {
buildSnapshot,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
widgetsActive,
withBookings,
type TripWidgetSnapshot,
type WidgetLabels,
} from '../../native/widgets';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Day, Place, TranslationFn, Trip } from '../../types';
import { formatTime } from '../../utils/formatters';
import { localDateStr } from '../../vacay/monthGrid';
const REFRESH_MS = 15 * 60 * 1000;
/** The forecast reaches about two weeks ahead; past that the widget shows no weather. */
const FORECAST_DAYS = 14;
function labels(t: TranslationFn): WidgetLabels {
return {
nextTrip: t('dashboard.nextTrip'),
today: t('native.widget.today'),
noTrip: t('native.widget.noTrip'),
inDays: t('native.widget.inDays'),
tomorrow: t('native.widget.tomorrow'),
nothingPlanned: t('native.widget.nothingPlanned'),
days: t('atlas.days'),
bookings: t('assistant.card.tripChat.phase.upcoming'),
noBookings: t('native.widget.noBookings'),
todos: t('todo.subtab.todo'),
noTodos: t('native.widget.noTodos'),
packing: t('packing.title'),
confirmed: t('reservations.confirmed'),
pending: t('reservations.pending'),
todayShort: t('native.widget.today'),
tomorrowShort: t('native.widget.tomorrow'),
now: t('native.widget.now'),
places: t('dashboard.places'),
travellers: t('native.widget.travellers'),
allDone: t('native.widget.dayDone'),
missing: t('native.widget.missing'),
countries: t('native.widget.countries'),
world: t('native.widget.world'),
group: t('native.widget.group'),
poll: t('native.widget.poll'),
votes: t('native.widget.votes'),
noMessages: t('native.widget.noMessages'),
departure: t('native.widget.departure'),
};
}
/** Where to ask for the weather: today's first place with coordinates, else any of the trip's. */
function weatherSpot(day: Day | undefined, places: Place[]): { lat: number; lng: number } | null {
const planned = (day?.assignments ?? []).map((a) => a.place).find((p) => p?.lat != null && p?.lng != null);
if (planned?.lat != null && planned.lng != null) return { lat: planned.lat, lng: planned.lng };
const any = places.find((p) => p.lat != null && p.lng != null);
return any?.lat != null && any.lng != null ? { lat: any.lat, lng: any.lng } : null;
}
interface SyncContext {
today: string;
language: string;
unit: string | undefined;
time: (value: string) => string;
}
async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time }: SyncContext) {
const [places, reservations, todos, packing, memberRows] = await Promise.all([
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
offlineDb.tripMembers.where('tripId').equals(trip.id).toArray(),
]);
const members = memberRows.length;
const names = [trip.owner_username, ...memberRows.map((m) => m.username)].filter((n): n is string => !!n);
const path = `/trips/${trip.id}`;
const place = tripPlace(places);
// While the trip runs, today where it is; before, its first day, once that is in forecast range.
const start = trip.start_date ?? today;
const weatherDate = start > today ? start : today;
const inRange = (Date.parse(weatherDate) - Date.parse(today)) / 86_400_000 <= FORECAST_DAYS;
const spot = weatherSpot(
days.find((d) => (d.date ?? '').slice(0, 10) === weatherDate),
places
);
const [weather, cover, bags, collab] = await Promise.all([
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
widgetCover(trip.cover_image),
packingDetail(trip.id, packing),
collabSummary(
trip.id,
`/trips/${trip.id}?tab=collab`,
[...new Set(names)],
useAuthStore.getState().user?.id ?? null
),
]);
const packed = packingSummary(packing, `/trips/${trip.id}?tab=listen`);
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
return {
reservations,
lists: { places, packing, todos },
extras: {
place,
cover,
placeCount: places.length,
// The members table lists the others; the owner comes on top.
memberCount: members > 0 ? members + 1 : 0,
weather,
bookings: bookingsAhead(reservations, { dayDates, today, tripPath: path, formatTime: time }),
todos: todosSummary(todos, `${path}?tab=listen`),
packing: packed && bags ? { ...packed, ...bags } : packed,
collab,
},
};
}
export default function NativeWidgetsSync(): null {
const { t, language } = useTranslation();
const { caps } = useNativeCapabilities();
const timeFormat = useSettingsStore((s) => s.settings.time_format);
const unit = useSettingsStore((s) => s.settings.temperature_unit);
/** The ids of the watch's ticks applied in this session, which the watch drops from its pending ones. */
const applied = useRef<string[]>([]);
useEffect(() => {
if (!widgetsActive()) return;
let cancelled = false;
/** One trip's snapshot with its day, as the widgets set to that trip show it. */
const tripSnapshot = async (trip: Trip | null, context: SyncContext, atlas: WidgetAtlas | null) => {
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
const extra = trip ? await tripExtras(trip, days, context) : null;
const plain = trip
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
: null;
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
return { day, days, extra, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
};
/** The hero trip in full for the watch, with the ticks from it applied so far. */
const publishToWatch = async (trip: Trip | null, main: Awaited<ReturnType<typeof tripSnapshot>>, today: string) => {
if (!watchActive()) return;
const lists = main.extra?.lists;
await publishWatch(
buildWatchSnapshot({
trip,
days: main.days,
places: lists?.places ?? [],
reservations: main.extra?.reservations ?? [],
packing: lists?.packing ?? [],
todos: lists?.todos ?? [],
today,
hour12: timeFormat === '12h',
place: main.extra?.extras.place,
cover: main.extra?.extras.cover,
weather: main.extra?.extras.weather,
applied: applied.current,
})
);
};
const sync = async () => {
const now = new Date();
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const context: SyncContext = { today, language, unit, time: (value) => formatTime(value, language, timeFormat) };
const trips = await offlineDb.trips.toArray();
const hero = pickTrip(trips, today);
// The countries do not depend on a trip: the atlas widget fills without one.
const atlas = await atlasSummary(language);
const main = await tripSnapshot(hero, context, atlas);
// Every trip running or ahead also gets its own, for widgets set to it in Edit Widget.
const choices: TripWidgetSnapshot[] = [];
for (const trip of widgetTripChoices(trips, today)) {
const own = trip.id === hero?.id ? main : await tripSnapshot(trip, context, null);
choices.push({
id: trip.id,
title: trip.title,
start: trip.start_date ?? '',
end: trip.end_date ?? '',
snapshot: own.snapshot,
});
}
if (cancelled) return;
const live = main.day
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
: null;
await publishWidgets(main.snapshot, live, choices);
await publishToWatch(hero, main, today);
};
/** The ticks made on the watch, written like a tap in the app, then the watch told. */
const drainWatch = async () => {
const actions = await takeWatchActions();
if (cancelled || actions.length === 0) return;
const { togglePackingItem, toggleTodoItem } = useTripStore.getState();
const ids = await applyWatchActions(actions, { packing: togglePackingItem, todo: toggleTodoItem });
applied.current = [...applied.current, ...ids].slice(-50);
await sync();
};
void sync().then(drainWatch);
const timer = window.setInterval(() => void sync(), REFRESH_MS);
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void sync().then(drainWatch);
});
const watchListener = watchActive() ? TrekShell.addListener('watchAction', () => void drainWatch()) : null;
return () => {
cancelled = true;
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
void watchListener?.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat, unit]);
return null;
}