Add JSDoc documentation to all TypeScript files
This commit is contained in:
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { BikeRoute, Station } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the bike route information section shown on event detail. */
|
||||
interface Props {
|
||||
bikeRoute: BikeRoute | null;
|
||||
loading: boolean;
|
||||
@@ -10,6 +11,11 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays cycling route duration, distance, and a map placeholder for the
|
||||
* event's origin → destination leg. Shows contextual empty states when no
|
||||
* origin is set or no route is available.
|
||||
*/
|
||||
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { Event } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the event detail header showing title, destination, times, and buffers. */
|
||||
interface Props {
|
||||
event: Event;
|
||||
leaveByTime: Date | null;
|
||||
@@ -9,6 +10,10 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the event title, destination, formatted date/time, data source,
|
||||
* and a three-column grid with leave-by time, arrive-by time, and buffer.
|
||||
*/
|
||||
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
|
||||
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the train journey list and optional final walk leg. */
|
||||
interface Props {
|
||||
journeys: Journey[];
|
||||
destStationLoading: boolean;
|
||||
@@ -15,6 +16,12 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders each journey card with train lines, departure/arrival times, platform,
|
||||
* transfer count, and delay/cancellation badges. Cards that arrive too late are
|
||||
* visually dimmed and bordered in red. Optionally shows the final walking leg
|
||||
* from the destination station to the event location.
|
||||
*/
|
||||
export function JourneyList({
|
||||
journeys,
|
||||
destStationLoading,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { WienerLinienStop } from '@timetoleave/core';
|
||||
import type { DepartureRow } from '../hooks/useWienerLinien';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the nearby public transport stops section. */
|
||||
interface Props {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
@@ -11,6 +12,11 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows public transport stops near the event destination and their upcoming
|
||||
* departures. Caps the departure list at 8 items to avoid overwhelming the UI.
|
||||
* Falls back to showing plain stop names when no departure data is available.
|
||||
*/
|
||||
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
|
||||
if (!loading && stops.length === 0 && !error) return null;
|
||||
|
||||
|
||||
@@ -32,8 +32,13 @@ const LIGHT = {
|
||||
highlight: '#e8f4fd',
|
||||
} as const;
|
||||
|
||||
/** Color token type — every theme variant shares the same keys. */
|
||||
export type AppColors = Record<keyof typeof DARK, string>;
|
||||
|
||||
/**
|
||||
* Returns the full color palette (dark or light) based on the current theme.
|
||||
* Derives from {@link useTheme} so it stays in sync with user preferences.
|
||||
*/
|
||||
export function useColors(): AppColors {
|
||||
const { dark } = useTheme();
|
||||
return dark ? DARK : LIGHT;
|
||||
|
||||
@@ -9,7 +9,19 @@ interface DepartureTimeResult {
|
||||
|
||||
/**
|
||||
* Calculate departure time based on selected transport mode.
|
||||
*
|
||||
* For train mode, picks the latest-departing journey that still arrives on time
|
||||
* (factoring in the final walking leg). For bike mode, simply subtracts the
|
||||
* cycling duration from the target arrival time.
|
||||
*
|
||||
* Mirrors the web app's useDepartureTime hook.
|
||||
*
|
||||
* @param eventTime - The scheduled event start time.
|
||||
* @param journeys - Available train journeys (may be null if not loaded yet).
|
||||
* @param bikeDurationSeconds - Cycling duration in seconds, or null if unavailable.
|
||||
* @param activeMode - The currently selected transport mode.
|
||||
* @param arrivalBufferMinutes - Minutes to arrive before the event starts.
|
||||
* @param trainWalkDurationSeconds - Walking time from destination station to event (seconds).
|
||||
*/
|
||||
export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
|
||||
import type { Station } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Geocodes a destination string to the nearest HAFAS station.
|
||||
*
|
||||
* Two-step process: first geocodes the address to lat/lng via Nominatim,
|
||||
* then sends those coordinates to HAFAS LocMatch to find the closest station.
|
||||
* Debounces lookups by 400 ms to avoid excessive API calls while typing.
|
||||
*/
|
||||
/** Intermediate shape returned by HAFAS LocMatch before we pick the best station. */
|
||||
interface HafasLocation {
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -10,6 +18,7 @@ interface HafasLocation {
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
|
||||
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Geocode a destination name to coordinates.
|
||||
* Debounces API calls by 400 ms. Automatically resets to null when the
|
||||
* destination is cleared or becomes empty.
|
||||
* Mirrors the web app's useGeocode hook.
|
||||
*/
|
||||
export function useGeocode(destination: string | undefined) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/** Theme storage key in AsyncStorage. */
|
||||
const THEME_KEY = '@timetoleave_theme';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
/** Returns the default theme. Mobile defaults to dark to match the web app. */
|
||||
function getDefaultTheme(): Theme {
|
||||
// React Native doesn't have window.matchMedia, but we can use a simple default
|
||||
// The web app uses a dark-first theme, so we match that default
|
||||
@@ -13,6 +15,11 @@ function getDefaultTheme(): Theme {
|
||||
|
||||
/**
|
||||
* Theme management for the mobile app.
|
||||
*
|
||||
* Persists the user's choice in AsyncStorage and initializes from storage
|
||||
* on mount (guarded by a ref so hot-reload doesn't re-read). Returns a
|
||||
* `dark` boolean and a `toggle` callback for switching themes.
|
||||
*
|
||||
* Mirrors the web app's useTheme hook.
|
||||
*/
|
||||
export function useTheme() {
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { WalkRoute } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Fetch walk route between two points.
|
||||
* Fetch walk route between two points using the OSRM walking router.
|
||||
* Skips the request if any coordinate is missing, resetting state to null.
|
||||
* Mirrors the web app's useWalkRoute hook.
|
||||
*/
|
||||
export function useWalkRoute(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/** Flattened departure row shown in the NearbyStops UI. */
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
@@ -12,6 +13,7 @@ export interface DepartureRow {
|
||||
const DEBOUNCE_MS = 400;
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
|
||||
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
|
||||
return {
|
||||
@@ -22,6 +24,16 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches nearby WienerLinien stops around given coordinates and their live
|
||||
* departures. Debounces initial lookups (400 ms) and refreshes departures
|
||||
* every 60 seconds. Uses a cancellation ref to prevent stale overwrites when
|
||||
* coordinates change mid-request.
|
||||
*
|
||||
* @param lat - Latitude of the point of interest.
|
||||
* @param lng - Longitude of the point of interest.
|
||||
* @param radius - Search radius in meters (defaults to 500).
|
||||
*/
|
||||
export function useWienerLinien(
|
||||
lat: number | undefined,
|
||||
lng: number | undefined,
|
||||
@@ -32,9 +44,12 @@ export function useWienerLinien(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stopped by the cleanup effect when coordinates change, so in-flight
|
||||
// monitor requests don't overwrite stale stop lists.
|
||||
const stopIdsRef = useRef<string[]>([]);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
|
||||
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
|
||||
if (stopIds.length === 0 || cancelledRef.current) return;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
@@ -13,6 +14,10 @@ import type { RootStack } from '../types/navigation';
|
||||
|
||||
const Root = createNativeStackNavigator<RootStack>();
|
||||
|
||||
/**
|
||||
* Root native stack navigator for the app.
|
||||
* Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
|
||||
*/
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
@@ -21,13 +26,22 @@ export default function AppNavigator() {
|
||||
<NavigationContainer>
|
||||
<Root.Navigator
|
||||
initialRouteName="EventList"
|
||||
screenOptions={{ headerStyle: { backgroundColor: '#17112A' }, headerTintColor: '#F4F1EA' }}
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#17112A' },
|
||||
headerTintColor: '#F4F1EA',
|
||||
headerTitle: () => (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Image source={require('../../assets/icon.png')} style={{ width: 28, height: 28 }} resizeMode="contain" />
|
||||
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time To Leave</Text>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
<Root.Screen name="EventList" component={EventListScreen} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} />
|
||||
</Root.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Polyfills for newer JavaScript features that React Native's Hermes engine
|
||||
* doesn't provide out of the box.
|
||||
*
|
||||
* - `String.prototype.toWellFormed` / `isWellFormed` — handles lone surrogates
|
||||
* by replacing them with the Unicode replacement character (U+FFFD).
|
||||
* - `ArrayBuffer.prototype.resizable` — required by newer Intl APIs.
|
||||
* - `SharedArrayBuffer` — stubbed so that libraries which check for its
|
||||
* presence (e.g. the Intl locale data) don't crash at runtime.
|
||||
*/
|
||||
|
||||
const globalScope = globalThis as Record<string, unknown>;
|
||||
const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
isWellFormed?: () => boolean;
|
||||
@@ -6,6 +17,11 @@ const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
|
||||
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
|
||||
|
||||
/**
|
||||
* Replace lone surrogates (U+D800…U+DBFF without a partner, or U+DC00…U+DFFF
|
||||
* without a lead) with the Unicode replacement character U+FFFD. Valid
|
||||
* surrogate pairs are kept as-is. Used by both `toWellFormed` and `isWellFormed`.
|
||||
*/
|
||||
function toWellFormedString(value: string): string {
|
||||
let result = '';
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Form screen for creating or editing an event.
|
||||
*
|
||||
* When navigated with `editEventId`, loads the existing event and populates
|
||||
* the form fields. Validates that the event time is in the future before saving.
|
||||
* Shows a transient success overlay for 1.5 s before popping back.
|
||||
*/
|
||||
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
@@ -22,6 +22,10 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'CalendarImport'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen for importing events from either an ICS calendar URL or the device's
|
||||
* native calendar. Deduplicates against already-imported events by ID.
|
||||
*/
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
@@ -31,6 +31,11 @@ type ScreenProps = {
|
||||
|
||||
type TransportMode = 'train' | 'bike';
|
||||
|
||||
/**
|
||||
* Compute the target arrival time at the destination station.
|
||||
* Subtracts both the arrival buffer (time before event) and the walking
|
||||
* duration from station to event location.
|
||||
*/
|
||||
function stationArrivalTarget(
|
||||
eventTime: Date,
|
||||
arrivalBufferMinutes: number,
|
||||
|
||||
@@ -21,6 +21,11 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'EventList'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Home screen showing a scrollable list of upcoming events with countdown
|
||||
* badges. Pull-to-refresh reloads events from storage. Reloads automatically
|
||||
* when the screen gains focus (so edits on other screens are reflected).
|
||||
*/
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -24,6 +25,13 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'Settings'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings screen for managing the origin station, notification preferences,
|
||||
* appearance (dark/light mode), and advanced options (walk/bike toggles).
|
||||
*
|
||||
* Station search is debounced by 400 ms. When the origin changes, all
|
||||
* scheduled push notifications are recalculated to use the new departure station.
|
||||
*/
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const { dark, toggle: toggleTheme } = useTheme();
|
||||
const colors = useColors();
|
||||
@@ -209,7 +217,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
|
||||
@@ -322,12 +334,13 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20 },
|
||||
container: { flex: 1 },
|
||||
contentContainer: { flexGrow: 1, padding: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
|
||||
input: {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ApiClient } from '@timetoleave/api-client';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the shared `ApiClient` class.
|
||||
* Reads the base URL from the Expo environment variable `EXPO_PUBLIC_API_BASE_URL`.
|
||||
*/
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
|
||||
export const api = new ApiClient(baseUrl);
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { Event as CoreEvent } from '@timetoleave/core';
|
||||
/**
|
||||
* Native calendar integration for the mobile app.
|
||||
* Reads events from device calendars and converts them to our internal format.
|
||||
*
|
||||
* Only events that have a non-empty `location` field are included, since the
|
||||
* app requires a destination to compute routes.
|
||||
*/
|
||||
|
||||
/** Requests calendar read permission and verifies the calendar service is available. */
|
||||
export async function ensureCalendarPermission(): Promise<boolean> {
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
||||
if (status !== 'granted')
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Re-exports the stable public API from `expo-notifications`.
|
||||
* Using public exports instead of internal `/build/` paths to avoid
|
||||
* breaking when the Expo package is updated.
|
||||
*/
|
||||
|
||||
// Public API re-exports from expo-notifications
|
||||
// Using stable public exports instead of internal /build/ paths
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -21,6 +29,10 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
|
||||
// ── 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 }>;
|
||||
@@ -39,6 +51,11 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
|
||||
// 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);
|
||||
@@ -48,6 +65,12 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
|
||||
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);
|
||||
@@ -70,6 +93,11 @@ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user