mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
9755330113
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.
242 lines
10 KiB
TypeScript
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;
|
|
}
|