Files
TREK/client/src/components/Layout/NativeWidgetsSync.tsx
T
Maurice fe7847fc08 feat(native): pick the trip a widget shows in Edit Widget
Widgets followed the dashboard's trip only, which is the one under way, so a
packing list or to-dos for a trip still being planned could not sit on the
home screen. The trip, today, bookings, to-dos, packing and group widgets now
take a trip in Edit Widget (an AppIntent with the trips running or ahead,
Automatic by default, which keeps following the dashboard); the world map
needs none.

- The app writes a snapshot per trip running or ahead (at most six, the
  dashboard's order) and the list to pick from, under their own keys
  (feature widgetTrips, so an older binary never mistakes one for the
  dashboard's); bags, chat and polls are kept ten minutes, covers per trip
- The widget extension targets iOS 17, which configurable widgets need; the
  apps are not released, so no installed widget goes away
- The choices and their texts in the 28 languages of the gallery
2026-11-06 21:41:21 +01:00

199 lines
8.2 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. Renders nothing; outside
* an iOS binary with the widgets it does nothing.
*/
import { App } from '@capacitor/app';
import { useEffect } from 'react';
import { offlineDb } from '../../db/offlineDb';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
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 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,
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);
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, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
};
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);
};
void sync();
const timer = window.setInterval(() => void sync(), REFRESH_MS);
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (isActive) void sync();
});
return () => {
cancelled = true;
window.clearInterval(timer);
void listener.then((handle) => handle.remove());
};
}, [caps, t, language, timeFormat, unit]);
return null;
}