Add leave reminder feature with browser notifications

This commit is contained in:
2026-05-10 10:32:55 +02:00
parent c0f34b9e2a
commit 48e6f5c7fd
9 changed files with 426 additions and 155 deletions
-150
View File
@@ -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
View File
@@ -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">
<EventsProvider>
<Header />
<div className="flex-1">{children}</div>
<Navbar />
</EventsProvider>
<ReminderSettingsProvider>
<EventsProvider>
<ReminderEngine />
<Header />
<div className="flex-1">{children}</div>
<Navbar />
</EventsProvider>
</ReminderSettingsProvider>
</body>
</html>
);
+24
View File
@@ -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>
);
};
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useReminder } from "@/hooks/useReminder";
export default function ReminderEngine() {
useReminder();
return null;
}
+95
View File
@@ -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>
);
}
+172
View File
@@ -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);
});
});
+57
View File
@@ -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]);
}
+53
View File
@@ -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;
}
+7
View File
@@ -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;
}