/** * Persistent event store backed by AsyncStorage. * * Every mutation (add, update, remove) also reschedules push notifications * so the user is reminded at the correct departure time. Settings changes * (origin station, buffer) trigger a full notification reschedule. */ import AsyncStorage from '@react-native-async-storage/async-storage'; import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core'; import * as Notifications from '../services/expoNotifications'; // ── Keys ─────────────────────────────────────────────────────── const EVENTS_KEY = '@timetoleave_events'; const ORIGIN_KEY = '@timetoleave_origin'; const NOTIFICATIONS_KEY = '@timetoleave_notifications'; const SELECTED_CALENDARS_KEY = '@timetoleave_selected_calendars'; // ── Default notification settings ───────────────────────────── const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = { bufferMinutes: 30, enabled: true, arrivalBufferMinutes: 5, showWalkingOption: true, showBikeOption: true, }; // ── Helpers ──────────────────────────────────────────────────── /** * Revives `eventTime` strings from JSON storage back into Date objects. * Returns an empty array if the stored data is corrupted. */ function reviveDates(json: string): Event[] { try { const parsed = JSON.parse(json) as Array; return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime) })); } catch { return []; } } async function getNotificationSettings(): Promise { const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY); return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS; } // ─────────────────────────────────────────────────────────────── // Notification scheduling utilities // ─────────────────────────────────────────────────────────────── /** * Calculate the leave-by time using a pessimistic fallback. * Because the store doesn't hold live journey data, it subtracts * both the arrival buffer and the notification buffer from the event time. */ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise { // Calculate target arrival time (event time minus arrival buffer) const targetArrivalTime = new Date(event.eventTime); targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes); // Fallback: event time minus arrival buffer minus buffer (no journey data) return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); } /** * Schedule three reminder notifications for an event: * 30 min, 10 min, and 0 min before the leave-by time. * Skips notifications that would fire in the past or more than 2 hours before * the event (push notifications are unreliable beyond that window). */ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise { const REMINDERS_MIN = [30, 10, 0]; const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000); const now = new Date(); for (const minutesBefore of REMINDERS_MIN) { const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000); if (triggerTime <= now || triggerTime < twoHoursBefore) continue; await Notifications.scheduleNotificationAsync({ content: { title: event.title, body: minutesBefore === 0 ? 'Zeit zu gehen!' : `${minutesBefore} Minuten bis du losmusst`, data: { eventId: event.id }, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: triggerTime }, }); } } /** * Schedule (or re-schedule) notifications for a single event. * Cancels any existing notifications for this event first to avoid duplicates. * No-ops if notifications are globally disabled in settings. */ async function scheduleEventNotification(event: Event): Promise { const settings = await getNotificationSettings(); if (!settings.enabled) return; const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); const existing = await Notifications.getAllScheduledNotificationsAsync(); for (const notif of existing.filter(n => n.content.data?.eventId === event.id)) { await Notifications.cancelScheduledNotificationAsync(notif.identifier); } await fireNotificationsForEvent(event, leaveByTime); } // ─────────────────────────────────────────────────────────────── // Events // ─────────────────────────────────────────────────────────────── export async function loadEvents(): Promise { const json = await AsyncStorage.getItem(EVENTS_KEY); return json ? reviveDates(json) : []; } export async function saveEvents(events: Event[]): Promise { const json = JSON.stringify(events); await AsyncStorage.setItem(EVENTS_KEY, json); } export async function addEvent(event: Event): Promise { const events = await loadEvents(); events.push(event); await saveEvents(events); await scheduleEventNotification(event); } export async function updateEvent(id: string, updates: Partial): Promise { const events = await loadEvents(); const updated = events.map((e) => (e.id === id ? { ...e, ...updates } : e)); await saveEvents(updated); // Reschedule notification for updated event const updatedEvent = updated.find((e) => e.id === id); if (updatedEvent) { await scheduleEventNotification(updatedEvent); } } export async function removeEvent(id: string, onDone?: () => void): Promise { const events = await loadEvents(); const filtered = events.filter((e) => e.id !== id); await saveEvents(filtered); // Cancel notifications for removed event - cancel one by one const existing = await Notifications.getAllScheduledNotificationsAsync(); const toCancel = existing.filter(n => n.content.data?.eventId === id); for (const notif of toCancel) { await Notifications.cancelScheduledNotificationAsync(notif.identifier); } onDone?.(); } // ─────────────────────────────────────────────────────────────── // Origin Station // ─────────────────────────────────────────────────────────────── export async function loadOriginStation(): Promise { const json = await AsyncStorage.getItem(ORIGIN_KEY); return json ? JSON.parse(json) : DEFAULT_ORIGIN_STATION; } export async function saveOriginStation(station: Station): Promise { const json = JSON.stringify(station); await AsyncStorage.setItem(ORIGIN_KEY, json); } // ─────────────────────────────────────────────────────────────── // Notification Settings // ─────────────────────────────────────────────────────────────── export async function loadNotificationSettings(): Promise { const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY); return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS; } export async function saveNotificationSettings( settings: ReminderSettings, ): Promise { const json = JSON.stringify(settings); await AsyncStorage.setItem(NOTIFICATIONS_KEY, json); } // ─────────────────────────────────────────────────────────────── // Calendar Selection // ──────────────────────────────────────────────────────────────── /** * Get the list of calendar IDs the user has chosen to sync. * Returns an empty array if no selection has been made yet (meaning * all calendars should be synced). */ export async function getSelectedCalendarIds(): Promise { const json = await AsyncStorage.getItem(SELECTED_CALENDARS_KEY); if (!json) return []; try { return JSON.parse(json) as string[]; } catch { return []; } } /** * Save the list of calendar IDs the user wants to sync. * Pass an empty array to reset to "sync all calendars". */ export async function saveSelectedCalendarIds(ids: string[]): Promise { await AsyncStorage.setItem(SELECTED_CALENDARS_KEY, JSON.stringify(ids)); } /** * Check whether the user has made an explicit calendar selection. * Returns true if a non-empty list is stored. */ export async function hasCalendarSelection(): Promise { const ids = await getSelectedCalendarIds(); return ids.length > 0; } export async function rescheduleAllNotifications(): Promise { const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]); await Notifications.cancelAllScheduledNotificationsAsync(); if (!settings.enabled) return; for (const event of events) { const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes); await fireNotificationsForEvent(event, leaveByTime); } }