44aaa32296
Remove emoji icons from journey display and replace with FontAwesome icons Add FontAwesome icon imports and styles for new icon usage Update calendar service to use FontAwesome icons instead of emojis Remove redundant emoji field from CalendarSourceInfo type Adjust journey scoring weights to prioritize fewer changes Remove emoji icons and simplify event metadata display
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
/**
|
|
* 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<Event & { eventTime: string }>;
|
|
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime) }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getNotificationSettings(): Promise<ReminderSettings> {
|
|
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<Date> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<Event[]> {
|
|
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
|
return json ? reviveDates(json) : [];
|
|
}
|
|
|
|
export async function saveEvents(events: Event[]): Promise<void> {
|
|
const json = JSON.stringify(events);
|
|
await AsyncStorage.setItem(EVENTS_KEY, json);
|
|
}
|
|
|
|
export async function addEvent(event: Event): Promise<void> {
|
|
const events = await loadEvents();
|
|
events.push(event);
|
|
await saveEvents(events);
|
|
await scheduleEventNotification(event);
|
|
}
|
|
|
|
export async function updateEvent(id: string, updates: Partial<Event>): Promise<void> {
|
|
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<void> {
|
|
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<Station | null> {
|
|
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
|
return json ? JSON.parse(json) : DEFAULT_ORIGIN_STATION;
|
|
}
|
|
|
|
export async function saveOriginStation(station: Station): Promise<void> {
|
|
const json = JSON.stringify(station);
|
|
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────
|
|
// Notification Settings
|
|
// ───────────────────────────────────────────────────────────────
|
|
|
|
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
|
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
|
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
|
}
|
|
|
|
export async function saveNotificationSettings(
|
|
settings: ReminderSettings,
|
|
): Promise<void> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<boolean> {
|
|
const ids = await getSelectedCalendarIds();
|
|
return ids.length > 0;
|
|
}
|
|
|
|
export async function rescheduleAllNotifications(): Promise<void> {
|
|
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);
|
|
}
|
|
}
|