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
|
|
||||||
+10
-5
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { EventsProvider } from "@/hooks/useEventsStore";
|
import { EventsProvider } from "@/hooks/useEventsStore";
|
||||||
|
import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
|
||||||
import Header from "@/app/layout/Header";
|
import Header from "@/app/layout/Header";
|
||||||
import Navbar from "@/app/layout/Navbar";
|
import Navbar from "@/app/layout/Navbar";
|
||||||
|
import ReminderEngine from "@/app/layout/ReminderEngine";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TimeToLeave",
|
title: "TimeToLeave",
|
||||||
@@ -17,11 +19,14 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<html lang="en" className="h-full antialiased">
|
||||||
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||||
<EventsProvider>
|
<ReminderSettingsProvider>
|
||||||
<Header />
|
<EventsProvider>
|
||||||
<div className="flex-1">{children}</div>
|
<ReminderEngine />
|
||||||
<Navbar />
|
<Header />
|
||||||
</EventsProvider>
|
<div className="flex-1">{children}</div>
|
||||||
|
<Navbar />
|
||||||
|
</EventsProvider>
|
||||||
|
</ReminderSettingsProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Button from "@/app/ui/Button";
|
import Button from "@/app/ui/Button";
|
||||||
import AddEventModal from "@/app/add-event/AddEventModal";
|
import AddEventModal from "@/app/add-event/AddEventModal";
|
||||||
|
import ReminderSettingsPanel from "@/app/ui/ReminderSettingsPanel";
|
||||||
import { useServerHealth } from "@/hooks/useServerHealth";
|
import { useServerHealth } from "@/hooks/useServerHealth";
|
||||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
@@ -13,6 +14,7 @@ type HeaderProps = {
|
|||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||||
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
||||||
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||||
const { status } = useServerHealth();
|
const { status } = useServerHealth();
|
||||||
const { events } = useEventsStore();
|
const { events } = useEventsStore();
|
||||||
const { dark, toggle } = useTheme();
|
const { dark, toggle } = useTheme();
|
||||||
@@ -48,10 +50,32 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
|||||||
>
|
>
|
||||||
{dark ? "☀️" : "🌙"}
|
{dark ? "☀️" : "🌙"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
<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>
|
</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 LiveStatus = null | true | false;
|
||||||
export type LocState = "pending" | "granted" | "denied";
|
export type LocState = "pending" | "granted" | "denied";
|
||||||
export type CalStatus = null | "loading" | "ok" | "error";
|
export type CalStatus = null | "loading" | "ok" | "error";
|
||||||
|
|
||||||
|
// ── Reminder Settings ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReminderSettings {
|
||||||
|
bufferMinutes: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user