Add leave reminder feature with browser notifications
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
# TimeToLeave
|
||||
|
||||
> Know when to leave the house so you're never late for your next meeting.
|
||||
|
||||
TimeToLeave is a web app that tells you **when to leave** for upcoming events based on real-time Austrian train (ÖBB) schedules — and optionally compares train journeys with bicycle routes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time train journeys** — Query the ÖBB HAFAS API for live departure times, delays, and platform information.
|
||||
- **Calendar import** — Import events from any ICS calendar (CalDAV, Google Calendar, etc.).
|
||||
- **Bicycle routing** — Compare train journeys with bicycle routes powered by OSRM, including distance, duration, and turn-by-turn directions.
|
||||
- **Train vs bicycle comparison** — See both travel modes side by side for each event, so you can decide what works best.
|
||||
- **Calendar month view** — Browse events in a month grid, see event indicators on dates, and drill into train/bike details per day.
|
||||
- **Geolocation support** — Use your browser's current location as the origin station.
|
||||
- **Countdown & status indicators** — Visual countdown timers and live status badges so you know exactly when to go.
|
||||
|
||||
## Architecture
|
||||
|
||||
Built with **Next.js App Router** (v16), **React 19**, **TypeScript**, and **Tailwind CSS v4**.
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & layout
|
||||
│ ├── api/ # Server-side API routes
|
||||
│ │ ├── hafas/ # ÖBB train journey search
|
||||
│ │ ├── calendar/ # ICS calendar parsing
|
||||
│ │ ├── geocode/ # Nominatim geocoding
|
||||
│ │ ├── bike-route/ # OSRM bicycle routing
|
||||
│ │ └── health/ # Server health check
|
||||
│ ├── ui/ # Shared UI components
|
||||
│ ├── page.tsx # Home page
|
||||
│ └── layout.tsx # Root layout
|
||||
├── hooks/ # Custom React hooks
|
||||
│ ├── useJourneys.ts # Fetch train journeys per event
|
||||
│ ├── useBikeRoute.ts # Fetch bicycle routes
|
||||
│ ├── useCalendar.ts # Calendar import & parsing
|
||||
│ ├── useEventsStore.tsx # Events state management
|
||||
│ ├── useGeolocation.ts # Browser geolocation
|
||||
│ ├── useOriginStation.ts # Origin station selection
|
||||
│ ├── useClock.ts # Live clock for countdowns
|
||||
│ └── useServerHealth.ts # API health monitoring
|
||||
├── lib/ # Shared utilities & clients
|
||||
│ ├── hafas-client.ts # ÖBB HAFAS API client
|
||||
│ ├── geocoding-client.ts # Nominatim geocoding client
|
||||
│ ├── bike-routing-client.ts# OSRM bicycle routing client
|
||||
│ ├── calendar-utils.ts # ICS parsing utilities
|
||||
│ ├── countdown-utils.ts # Countdown time calculations
|
||||
│ ├── formatting.ts # Date/time formatting helpers
|
||||
│ └── constants.ts # App-wide constants
|
||||
├── types/ # TypeScript type definitions
|
||||
└── test/ # Test utilities
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- npm (or yarn / pnpm)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
### Build & Start
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Build and run with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
The app will be available on port `3000` by default. Override the port or any other variable by creating a `.env` file at the project root:
|
||||
|
||||
```env
|
||||
PORT=8080
|
||||
HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe
|
||||
NOMINATIM_URL=https://nominatim.openstreetmap.org
|
||||
NOMINATIM_USER_AGENT=TimeToLeave/2.0
|
||||
OSRM_URL=https://router.project-osrm.org
|
||||
```
|
||||
|
||||
The `Dockerfile` uses a 3-stage build (deps → builder → runner) with `output: "standalone"`, producing a small final image that runs as a non-root user. Stop and remove the container with `docker compose down`.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
npm test # Run tests once
|
||||
npm run test:watch # Run tests in watch mode
|
||||
```
|
||||
|
||||
### Linting & Type Checking
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables can be configured (defaults are provided for development):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HAFAS_URL` | ÖBB HAFAS API endpoint | `https://fahrplan.oebb.at/bin/mgate.exe` |
|
||||
| `NOMINATIM_URL` | Nominatim geocoding endpoint | `https://nominatim.openstreetmap.org` |
|
||||
| `NOMINATIM_USER_AGENT` | User-Agent for Nominatim requests | `TimeToLeave/2.0` |
|
||||
| `OSRM_URL` | OSRM routing endpoint | `https://router.project-osrm.org` |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
|----------|-----------|
|
||||
| Framework | [Next.js 16](https://nextjs.org/) (App Router) |
|
||||
| UI | [React 19](https://react.dev/) + [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||
| Language | [TypeScript 5](https://www.typescriptlang.org/) |
|
||||
| Train Data | [ÖBB HAFAS API](https://fahrplan.oebb.at/) |
|
||||
| Geocoding | [Nominatim](https://nominatim.openstreetmap.org/) |
|
||||
| Bicycle Routing | [OSRM](https://project-osrm.org/) |
|
||||
| Calendar | [node-ical](https://www.npmjs.com/package/node-ical) |
|
||||
| Dates | [date-fns](https://date-fns.org/) |
|
||||
| Testing | [Vitest](https://vitest.dev/) + [React Testing Library](https://testing-library.com/) |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Add events** — Manually enter events or import from an ICS calendar URL.
|
||||
2. **Pick an origin** — Use your current location or search for a departure station.
|
||||
3. **Get journey info** — The app queries ÖBB for real-time train schedules and optionally calculates bicycle routes via OSRM.
|
||||
4. **Decide when to leave** — Each event shows a countdown, the best available train, and (if enabled) a bicycle alternative with estimated travel time.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { EventsProvider } from "@/hooks/useEventsStore";
|
||||
import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
|
||||
import Header from "@/app/layout/Header";
|
||||
import Navbar from "@/app/layout/Navbar";
|
||||
import ReminderEngine from "@/app/layout/ReminderEngine";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TimeToLeave",
|
||||
@@ -17,11 +19,14 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<ReminderSettingsProvider>
|
||||
<EventsProvider>
|
||||
<ReminderEngine />
|
||||
<Header />
|
||||
<div className="flex-1">{children}</div>
|
||||
<Navbar />
|
||||
</EventsProvider>
|
||||
</ReminderSettingsProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState } from "react";
|
||||
import Button from "@/app/ui/Button";
|
||||
import AddEventModal from "@/app/add-event/AddEventModal";
|
||||
import ReminderSettingsPanel from "@/app/ui/ReminderSettingsPanel";
|
||||
import { useServerHealth } from "@/hooks/useServerHealth";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
@@ -13,6 +14,7 @@ type HeaderProps = {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const { status } = useServerHealth();
|
||||
const { events } = useEventsStore();
|
||||
const { dark, toggle } = useTheme();
|
||||
@@ -48,10 +50,32 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
>
|
||||
{dark ? "☀️" : "🌙"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
||||
{showSettingsModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-sm relative">
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(false)}
|
||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Settings</h2>
|
||||
<ReminderSettingsPanel />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useReminder } from "@/hooks/useReminder";
|
||||
|
||||
export default function ReminderEngine() {
|
||||
useReminder();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useReminderSettings } from "@/hooks/useReminderSettings";
|
||||
|
||||
type ReminderSettingsPanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
|
||||
const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings();
|
||||
|
||||
const permission =
|
||||
typeof window !== "undefined" && "Notification" in window
|
||||
? Notification.permission
|
||||
: "unavailable";
|
||||
|
||||
const statusLabel =
|
||||
permission === "granted"
|
||||
? "Notifications enabled"
|
||||
: permission === "denied"
|
||||
? "Notifications blocked — check browser settings"
|
||||
: "Notifications not yet requested";
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Leave reminders
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Buffer minutes */}
|
||||
{enabled && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Remind me{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
(minutes before event)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="buffer-minutes"
|
||||
type="range"
|
||||
min={1}
|
||||
max={120}
|
||||
step={1}
|
||||
value={bufferMinutes}
|
||||
onChange={(e) => setBufferMinutes(Number(e.target.value))}
|
||||
className="flex-1 accent-blue-600"
|
||||
/>
|
||||
<output
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{bufferMinutes}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission status */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
|
||||
{permission !== "granted" && permission !== "denied" && (
|
||||
<button
|
||||
onClick={() => Notification.requestPermission()}
|
||||
className="mt-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
Request permission now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useReminder } from "../useReminder";
|
||||
import { EventsProvider } from "../useEventsStore";
|
||||
import { ReminderSettingsProvider } from "../useReminderSettings";
|
||||
import type { Event } from "@/types";
|
||||
import React from "react";
|
||||
|
||||
// ── Notification mock ──
|
||||
|
||||
const mockInstances: unknown[] = [];
|
||||
|
||||
class MockNotification {
|
||||
static permission: "granted" | "denied" | "default" = "granted";
|
||||
static requestPermission = vi.fn(() => Promise.resolve(MockNotification.permission));
|
||||
static instances = mockInstances;
|
||||
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: NotificationOptions,
|
||||
) {
|
||||
mockInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function seedEvents(events: Event[]) {
|
||||
localStorage.setItem("ttl_events", JSON.stringify(events));
|
||||
}
|
||||
|
||||
function eventInMinutes(offsetMinutes: number, id = "evt-1"): Event {
|
||||
return {
|
||||
id,
|
||||
title: "Team Standup",
|
||||
destination: "Graz Hbf",
|
||||
eventTime: new Date(Date.now() + offsetMinutes * 60_000),
|
||||
source: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ReminderSettingsProvider>
|
||||
<EventsProvider>{children}</EventsProvider>
|
||||
</ReminderSettingsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
describe("useReminder", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
MockNotification.permission = "granted";
|
||||
mockInstances.length = 0;
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, "Notification", {
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("fires a notification when event is within reminder window", () => {
|
||||
// Event in 10 min, buffer 15 min → reminder already passed
|
||||
seedEvents([eventInMinutes(10)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(1);
|
||||
const notif = mockInstances[0] as MockNotification;
|
||||
expect(notif.title).toBe("Time to leave!");
|
||||
expect(notif.options?.body).toContain("Team Standup");
|
||||
});
|
||||
|
||||
it("does not fire when event is too far away", () => {
|
||||
// Event in 120 min, buffer 15 min → not yet time
|
||||
seedEvents([eventInMinutes(120)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fire for past events", () => {
|
||||
seedEvents([
|
||||
{
|
||||
id: "evt-past",
|
||||
title: "Old Meeting",
|
||||
destination: "Graz Hbf",
|
||||
eventTime: new Date(Date.now() - 60_000),
|
||||
source: "manual",
|
||||
},
|
||||
]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fire when reminder is disabled", () => {
|
||||
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 15, enabled: false }));
|
||||
seedEvents([eventInMinutes(10)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(0);
|
||||
});
|
||||
|
||||
it("fires at most once per event across multiple polls", () => {
|
||||
seedEvents([eventInMinutes(10)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
// Already fired once on mount + initial check
|
||||
const firstCount = mockInstances.length;
|
||||
|
||||
// Advance multiple intervals
|
||||
act(() => vi.advanceTimersByTime(30_000));
|
||||
act(() => vi.advanceTimersByTime(30_000));
|
||||
act(() => vi.advanceTimersByTime(30_000));
|
||||
|
||||
expect(mockInstances.length).toBe(firstCount);
|
||||
});
|
||||
|
||||
it("requests permission if not yet granted and event is due", () => {
|
||||
MockNotification.permission = "default";
|
||||
seedEvents([eventInMinutes(10)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(MockNotification.requestPermission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when Notification API is unavailable", () => {
|
||||
Object.defineProperty(window, "Notification", {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
});
|
||||
seedEvents([eventInMinutes(10)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(0);
|
||||
});
|
||||
|
||||
it("respects custom buffer minutes", () => {
|
||||
// Buffer = 30 min, event in 20 min → reminder passed
|
||||
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 30, enabled: true }));
|
||||
seedEvents([eventInMinutes(20)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fire when buffer is larger than time to event", () => {
|
||||
// Buffer = 60 min, event in 120 min → reminder at now+60, not yet
|
||||
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 60, enabled: true }));
|
||||
seedEvents([eventInMinutes(120)]);
|
||||
|
||||
renderHook(() => useReminder(), { wrapper });
|
||||
|
||||
expect(mockInstances.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEventsStore } from "./useEventsStore";
|
||||
import { useReminderSettings } from "./useReminderSettings";
|
||||
|
||||
const POLLS_MS = 30_000; // 30s — matches useClock cadence
|
||||
|
||||
export function useReminder() {
|
||||
const { events } = useEventsStore();
|
||||
const { bufferMinutes, enabled } = useReminderSettings();
|
||||
const firedRef = useRef(new Set<string>());
|
||||
|
||||
const check = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof Notification === "undefined") return;
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = events
|
||||
.filter((e) => e.eventTime > now)
|
||||
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
|
||||
|
||||
for (const event of upcoming) {
|
||||
const reminderTime = new Date(event.eventTime.getTime() - bufferMinutes * 60_000);
|
||||
if (now >= reminderTime && !firedRef.current.has(event.id)) {
|
||||
firedRef.current.add(event.id);
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification("Time to leave!", {
|
||||
body: `${event.title} starts in ${bufferMinutes} minutes`,
|
||||
tag: `reminder-${event.id}`,
|
||||
});
|
||||
} else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then((perm) => {
|
||||
if (perm === "granted") {
|
||||
new Notification("Time to leave!", {
|
||||
body: `${event.title} starts in ${bufferMinutes} minutes`,
|
||||
tag: `reminder-${event.id}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entries for events that no longer exist
|
||||
const ids = new Set(upcoming.map((e) => e.id));
|
||||
for (const id of firedRef.current) {
|
||||
if (!ids.has(id)) firedRef.current.delete(id);
|
||||
}
|
||||
}, [events, bufferMinutes, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
const id = setInterval(check, POLLS_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [check]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
|
||||
import { ReminderSettings } from "@/types";
|
||||
|
||||
const STORAGE_KEY = "ttl_reminder_settings";
|
||||
const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true };
|
||||
|
||||
function loadFromStorage(): ReminderSettings {
|
||||
if (typeof window === "undefined") return DEFAULTS;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULTS;
|
||||
return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
interface ReminderContextType extends ReminderSettings {
|
||||
setBufferMinutes: (minutes: number) => void;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
|
||||
|
||||
export function ReminderSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<ReminderSettings>(loadFromStorage);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}, [settings]);
|
||||
|
||||
const setBufferMinutes = useCallback((minutes: number) => {
|
||||
setSettings((prev) => ({ ...prev, bufferMinutes: Math.max(1, Math.min(120, minutes)) }));
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((enabled: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, enabled }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}>
|
||||
{children}
|
||||
</ReminderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useReminderSettings() {
|
||||
const context = useContext(ReminderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useReminderSettings must be used within a ReminderSettingsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -88,3 +88,10 @@ export type ServerStatus = null | true | false;
|
||||
export type LiveStatus = null | true | false;
|
||||
export type LocState = "pending" | "granted" | "denied";
|
||||
export type CalStatus = null | "loading" | "ok" | "error";
|
||||
|
||||
// ── Reminder Settings ───────────────────────────────────────
|
||||
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user