From 48e6f5c7fded568fedfbf7791df7a81f18c790fb Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sun, 10 May 2026 10:32:55 +0200 Subject: [PATCH] Add leave reminder feature with browser notifications --- README.md | 150 -------------------- src/app/layout.tsx | 15 +- src/app/layout/Header.tsx | 24 ++++ src/app/layout/ReminderEngine.tsx | 8 ++ src/app/ui/ReminderSettingsPanel.tsx | 95 +++++++++++++ src/hooks/__tests__/useReminder.test.tsx | 172 +++++++++++++++++++++++ src/hooks/useReminder.ts | 57 ++++++++ src/hooks/useReminderSettings.tsx | 53 +++++++ src/types/index.ts | 7 + 9 files changed, 426 insertions(+), 155 deletions(-) delete mode 100644 README.md create mode 100644 src/app/layout/ReminderEngine.tsx create mode 100644 src/app/ui/ReminderSettingsPanel.tsx create mode 100644 src/hooks/__tests__/useReminder.test.tsx create mode 100644 src/hooks/useReminder.ts create mode 100644 src/hooks/useReminderSettings.tsx diff --git a/README.md b/README.md deleted file mode 100644 index 2aee71e..0000000 --- a/README.md +++ /dev/null @@ -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 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4b38773..17464d2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - -
-
{children}
- - + + + +
+
{children}
+ + + ); diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index d356784..88e20df 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -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 = ({ 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 = ({ className = "" }) => { > {dark ? "☀️" : "🌙"} + setShowAddEventModal(false)} /> + {showSettingsModal && ( +
+
+ +

Settings

+ +
+
+ )}
); }; diff --git a/src/app/layout/ReminderEngine.tsx b/src/app/layout/ReminderEngine.tsx new file mode 100644 index 0000000..456a0f3 --- /dev/null +++ b/src/app/layout/ReminderEngine.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useReminder } from "@/hooks/useReminder"; + +export default function ReminderEngine() { + useReminder(); + return null; +} diff --git a/src/app/ui/ReminderSettingsPanel.tsx b/src/app/ui/ReminderSettingsPanel.tsx new file mode 100644 index 0000000..c0c8767 --- /dev/null +++ b/src/app/ui/ReminderSettingsPanel.tsx @@ -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 ( +
+ {/* Toggle */} +
+ + Leave reminders + + +
+ + {/* Buffer minutes */} + {enabled && ( +
+ +
+ setBufferMinutes(Number(e.target.value))} + className="flex-1 accent-blue-600" + /> + + {bufferMinutes} + +
+
+ )} + + {/* Permission status */} +
+

{statusLabel}

+ {permission !== "granted" && permission !== "denied" && ( + + )} +
+
+ ); +} diff --git a/src/hooks/__tests__/useReminder.test.tsx b/src/hooks/__tests__/useReminder.test.tsx new file mode 100644 index 0000000..8768330 --- /dev/null +++ b/src/hooks/__tests__/useReminder.test.tsx @@ -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 ( + + {children} + + ); +} + +// ── 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); + }); +}); diff --git a/src/hooks/useReminder.ts b/src/hooks/useReminder.ts new file mode 100644 index 0000000..3efcf19 --- /dev/null +++ b/src/hooks/useReminder.ts @@ -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()); + + 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]); +} diff --git a/src/hooks/useReminderSettings.tsx b/src/hooks/useReminderSettings.tsx new file mode 100644 index 0000000..14c470f --- /dev/null +++ b/src/hooks/useReminderSettings.tsx @@ -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(undefined); + +export function ReminderSettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(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 ( + + {children} + + ); +} + +export function useReminderSettings() { + const context = useContext(ReminderContext); + if (context === undefined) { + throw new Error("useReminderSettings must be used within a ReminderSettingsProvider"); + } + return context; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9e34adf..906f7d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; +}