Files
TREK/client/src/components/Layout/NativeWidgetsSync.tsx
T
Maurice afaacec61a fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
2026-11-08 10:15:20 +01:00

246 lines
11 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 { onWatchListsChange } from '../../native/watchListChanges';
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;
// A tick here, or one another member made, reaches the watch at once rather than on the next refresh.
const stopListWatch = watchActive() ? onWatchListsChange(() => void sync()) : null;
return () => {
cancelled = true;
stopListWatch?.();
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
void watchListener?.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat, unit]);
return null;
}