14 Commits

Author SHA1 Message Date
fegger 3c6df95a86 Refactor monorepo structure and replace hardcoded colors
Add TypeScript project references, update ESLint configs to support
ESM modules, and introduce brand semantic color tokens across the web
app. Add comprehensive documentation for architecture, API reference,
development setup, and user guide.
2026-05-12 21:22:40 +02:00
fegger 98e74ee48d Update lint and typecheck scripts for all packages
Add linting to the mobile app and include core and api-client
packages in the root lint, typecheck, and test scripts. Ignore
node_modules in all subdirectories and clean up stale test results.
Update lint and typecheck scripts for all packages

Add ESLint configuration for mobile app and shared packages.
Rename mobile jest config to .cjs and enable ESM. Include
packages/core and packages/api-client in monorepo lint,
typecheck, and test scripts.
2026-05-12 17:47:47 +02:00
fegger 2eeae9a27b Add arrival buffer and transport options to notification settings
Update notification calculation to use arrival buffer
Add advanced settings toggle for arrival buffer and transport options
Fix reverse geocode call in SettingsScreen
2026-05-12 15:05:23 +02:00
fegger 35971596b3 Add API security guards, rate limiter, and manual test checklist
Implement strict CORS enforcement and per-IP rate limiting in the Next.js middleware. Add input validation helpers for
coordinates and request body size limits. Introduce SSRF protection for calendar URL fetching. Update mobile settings to
support new transport options and arrival buffers. Include a comprehensive manual testing checklist for integration
verification.
2026-05-12 14:42:11 +02:00
fegger 863996f06c Update BikeSection and EventCard with arrival buffer and walk options 2026-05-12 13:52:35 +02:00
fegger 1851d2ed47 Add TimeToLeave feature checklist and plan (50)
Add TimeToLeave feature checklist and plan
2026-05-12 13:17:17 +02:00
fegger eac4f6e216 Revamp branding, UI styling, and HAFAS integration
- Update BRAND_GUIDELINES.md with new melting clock logo concept
- Rebrand UI components with new violet-to-pink gradient color scheme
- Implement HAFAS authentication headers and GET support in route
- Update logo SVGs to match new brand guidelines
- Fix HAFAS time format to exclude millisecond padding
- Update default station to Mödling Bahnhof
- Bump workspace package versions to 1.0.0
2026-05-12 09:44:37 +02:00
fegger 2c65dc1d5a Update README.md 2026-05-11 23:02:58 +02:00
fegger b87acfd0e1 Update README to reflect logo and feature scope
Removes mention of bike route calculation from the main
description as this functionality is not implemented in
the current scope.
2026-05-11 23:01:06 +02:00
fegger 08794eae05 Update branding guidelines and assets
Adds a comprehensive `BRAND_GUIDELINES.md` file detailing brand assets, color palettes, and typography.

This commit also introduces multiple SVG assets for various logo usages (icon, horizontal, dark mode), updates the main
`layout.tsx` metadata, and adds the necessary component files (`LogoIcon.tsx`, `LogoHorizontal.tsx`, etc.) to support
these new assets.
2026-05-11 22:59:03 +02:00
fegger dc5b40ff6e Update .gitignore 2026-05-11 21:10:42 +02:00
fegger 4366f781d3 chore: remove agent config files from git tracking 2026-05-11 21:08:02 +02:00
fegger ea88e9d34a chore: remove IDE and agent_loop dirs from git tracking 2026-05-11 21:05:27 +02:00
fegger b541c809b2 Update .gitignore 2026-05-11 21:02:31 +02:00
118 changed files with 7518 additions and 2675 deletions
+14 -3
View File
@@ -2,6 +2,7 @@
# dependencies # dependencies
/node_modules /node_modules
**/node_modules
# testing # testing
/coverage /coverage
@@ -29,10 +30,20 @@ npm-debug.log*
next-env.d.ts next-env.d.ts
.aider* .aider*
# editor / agent tooling
.claude/
.idea/
.zed/
AGENTS.md
CLAUDE.md
AIDER_RULES.md
.aider.chat.history.md
.aider.input.history
# agent loop
agent_loop/
# agent loop generated output # agent loop generated output
agent_loop/logs/
agent_loop/runs/
agent_loop/__pycache__/
logs/ logs/
runs/ runs/
-10
View File
@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TimeToLeave.iml" filepath="$PROJECT_DIR$/.idea/TimeToLeave.iml" />
</modules>
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/oebb-planner-app" vcs="Git" />
</component>
</project>
-114
View File
@@ -1,114 +0,0 @@
# Rewrite Agent Rules
These rules apply when implementing the Next.js rewrite on the `rewrite/next` branch.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement**
Rules:
- The ✅ column is yours; the ✔️ column belongs to the review agent.
- After completing each numbered item, mark its ✅ box by changing `[ ]` to `[x]`.
- If you complete an optional item (`[~]`), change it to `[x]`. If you skip it, leave it as `[~]`.
- Before starting any item in a new phase, read `CHECKLIST.md` and confirm that every **required** (`[ ]`/`[x]`) item in all preceding phases has `[x]` in both ✅ and ✔️. Items marked `[~]` in both columns do not need to be completed first.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Rewrite Context
- `REWRITE_PLAN.md` is the guiding plan for the migration from the current CRA + Express app to a Next.js App Router + TypeScript app.
- When working on the rewrite, follow the migration phases in `REWRITE_PLAN.md` unless the user explicitly asks for a different order.
- Treat each numbered migration item as a checkpoint: implement it, update its ✅ box in `CHECKLIST.md`, add or update tests, run the relevant verification, then continue.
- Prefer building the new Next.js structure in parallel until feature parity is proven. Do not delete `server/`, `oebb-planner-app/`, or `oebb-planner.jsx` before equivalent Next.js behavior is implemented, tested, and the user has clearly asked for cleanup.
- Preserve existing API contracts and user-visible behavior during migration unless the rewrite plan or user request explicitly changes them.
- Use `npm` consistently because the existing project uses `package-lock.json`.
## Work Step By Step
- Start by reading the relevant files and identifying the smallest safe next step.
- State the plan before making non-trivial changes.
- Implement one coherent change at a time.
- After each step, review the diff and check whether it still matches the intended behavior.
- Do not move on to the next step while the current step has unresolved compile errors, failing tests, or obvious regressions.
- Prefer small, targeted edits over broad rewrites.
- Preserve existing behavior unless the user explicitly asks to change it.
- When a task spans multiple rewrite phases, complete one vertical slice at a time where practical: type or library code, route or hook, UI integration, tests, then verification.
- Keep reusable logic in `src/lib`, side effects in hooks or route handlers, and shared contracts in `src/types`.
## Testing Requirements
- Add or update tests for every new feature, bug fix, and behavior change.
- Put tests near the code they cover and follow the existing test style.
- Cover the main success path, important edge cases, and failure behavior.
- Do not remove or weaken tests just to make the suite pass.
- If a change cannot reasonably be tested, explain why and add the closest practical verification.
- For the Next.js rewrite, prefer unit tests for `src/lib`, route tests for `src/app/api`, and component smoke or behavior tests for UI components.
- Mock external services in automated tests, including ÖBB HAFAS, Nominatim, OSRM, geolocation, time, and calendar downloads. Do not make tests depend on live network availability.
- Test TypeScript data shapes and boundary parsing where API responses are transformed into app types.
## Verification Before Moving On
- Run the narrowest relevant tests after each meaningful change.
- Run the broader project checks before finishing.
- For server changes, run:
```bash
cd server
npm test
```
- For React app changes, run:
```bash
cd oebb-planner-app
CI=true npm test -- --watchAll=false
npm run build
```
- For the Next.js rewrite, once the root Next.js project exists, run the relevant root checks instead:
```bash
npm test
npm run build
```
- If available, also run type-checking and linting scripts before finishing:
```bash
npm run typecheck
npm run lint
```
- If a change touches both server and app behavior, run both sets of checks.
- If a command fails, stop, inspect the failure, fix the cause, and rerun the command.
- Do not claim the work is complete until the relevant checks pass, or until the remaining blocker is clearly reported.
## Quality Bar
- Make sure additions do not introduce compile errors, lint errors, runtime crashes, or broken imports.
- Check that public APIs, endpoint contracts, props, and data shapes remain compatible with existing callers.
- Keep error handling explicit and user-facing failures understandable.
- Avoid hidden global state, timing assumptions, and network-dependent tests unless the project already uses that pattern.
- Keep dependencies unchanged unless they are necessary for the task and justified.
- Do not commit generated artifacts, caches, logs, or local environment files.
- Keep TypeScript strictness intact once introduced. Do not use `any` as a shortcut around unclear domain types.
- Keep server-only code out of client components. Route handlers and `src/lib` clients that use secrets, privileged headers, or upstream service details must not be imported into browser-only code.
- Respect Nominatim usage requirements when implementing geocoding: configurable base URL, clear user agent, rate-limit-aware caching, and no direct browser calls to the public service.
- Keep OSRM and HAFAS clients behind API routes or server-side utilities so failures can be normalized and tested.
- For UI work, preserve accessibility basics: semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
## Completion Checklist
Before finishing a step, confirm:
- The requested behavior is implemented.
- The ✅ box for the corresponding item in `CHECKLIST.md` is checked.
- The change matches the relevant phase or numbered item in `REWRITE_PLAN.md`, when applicable.
- Tests were added or updated where appropriate.
- Relevant tests and build checks pass.
- The change is scoped to the request.
- No unrelated user changes were overwritten.
- Old implementation files were not removed unless parity is tested and cleanup was requested.
- Any limitations or skipped checks are reported clearly.
-5
View File
@@ -1,5 +0,0 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}
-15
View File
@@ -1,15 +0,0 @@
[
{
"label": "Run Wiener Linien agent loop",
"command": "python",
"args": [
"ttl_agent_gemma4.py",
"--workspace", "/home/fegger/Code/TimeToLeave",
"--run-until-done"
],
"cwd": "$ZED_WORKTREE_ROOT/agent_loop",
"use_new_terminal": true,
"allow_concurrent_runs": false,
"reveal": "always"
}
]
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-45
View File
@@ -1,45 +0,0 @@
# Aider Coding Rules
You are editing a real repository. Correctness is more important than speed.
## Operating Rules
1. Before editing, identify the exact requested task and restate the concrete files or behaviors that must
change.
2. Read the relevant existing files before making changes. Do not infer APIs, imports, types, or component
contracts from memory.
3. Implement every requested step. Do not skip checklist items, tests, wiring, exports, or documentation
updates that are part of the task.
4. Keep changes minimal and scoped. Do not refactor unrelated code, rename unrelated symbols, or change
behavior outside the task.
5. Prefer existing project patterns over new abstractions.
6. If a requirement is ambiguous, choose the smallest implementation that satisfies the written request and
state the assumption.
## Code Quality Rules
1. Do not introduce type errors, broken imports, missing exports, unused variables, or dead code.
2. Do not use placeholder code, TODOs, stubs, fake implementations, or comments claiming work is done when
it is not.
3. Preserve existing public APIs unless the task explicitly changes them.
4. Handle null, undefined, empty arrays, failed network calls, and invalid user input where relevant.
5. Keep async behavior explicit. Await promises that must complete before continuing.
6. Do not weaken or delete tests to make checks pass.
## Step Completion Rules
Before finishing, verify this checklist mentally and fix any failures:
- The requested behavior is fully implemented.
- Every required file is created or updated.
- All changed imports resolve.
- All changed types are valid.
- Existing behavior not mentioned in the task is preserved.
- Tests were added or updated when behavior changed.
- No generated files, build artifacts, cache files, or secrets were edited.
- The final response lists what changed and any checks that still need to be run.
## If You Are Unsure
Do not guess. Inspect the repository first. If still uncertain, make the smallest safe change and
explicitly mention the assumption in the final response.
+84
View File
@@ -0,0 +1,84 @@
# TimeToLeave Brand Guidelines
## Logo
The TimeToLeave logo features a **melting clock** flowing into a right-pointing departure arrow, symbolizing "it's time to go" — time literally dripping away as you head out the door.
### Meaning
- **Melting clock** = Time awareness, urgency, fluidity — like Dali's persistence of memory
- **Violet→Magenta gradient** = Creativity, energy, modernity
- **Hot pink arrow** (#FF2D8D) = Departure, leaving, forward motion
- **Dark background** = Premium, sleek, focused
## Colors
### Primary Gradient
| Name | Hex | Usage |
|------|-----|-------|
| Violet | `#8B5CF6` | Gradient start |
| Magenta | `#B23CFF` | Gradient mid |
| Pink | `#D946EF` | Gradient end |
| Hot Pink | `#FF2D8D` | Accents, arrows, "To" in wordmark |
### Text
| Name | Hex | Usage |
|------|-----|-------|
| Off-White | `#F4F1EA` | Primary text on dark backgrounds |
### Background
| Name | Hex | Usage |
|------|-----|-------|
| Deep Space | `#03030A` | Outer background |
| Night | `#090816` | Inner background |
| Twilight | `#17112A` | Highlights, glows |
### Status Colors (Countdown)
| Status | Color | Meaning |
|--------|-------|---------|
| Red | `#FF3B30` | Leave now / Late |
| Orange | `#FF9500` | Getting close |
| Yellow | `#FFCC00` | On track |
| Green | `#34C759` | Plenty of time |
| Blue | `#5AC8FA` | Confirmed / Done |
## Typography
- **Primary:** Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif
- **Weights:** 800 for headlines, 700 for headings, 600 for semibold, 400 for body, 300 for captions
- **Letter spacing:** -0.02em for headings (tighter, more modern)
## Logo Variants
### Icon Only
Use `LogoIcon` component for favicons, app icons, loading states.
### Horizontal Logo
Use `LogoHorizontal` component for headers, navigation, about pages.
### Full Logo (SVG)
Use `timetoleave_dark_logo.svg` for downloads, print, marketing materials.
## Usage
```tsx
import { LogoIcon, LogoHorizontal } from "@/app/ui/logos";
// Icon only (48px)
<LogoIcon size={48} />
// Horizontal header logo
<LogoHorizontal height={32} />
// Custom sizing
<LogoIcon size={128} className="drop-shadow-lg" />
```
## File Locations
| File | Purpose |
|------|---------|
| `apps/web/src/app/ui/LogoIcon.tsx` | React icon component |
| `apps/web/src/app/ui/LogoHorizontal.tsx` | React horizontal logo |
| `apps/web/src/app/icon.svg` | Web favicon (auto-generated by Next.js) |
| `apps/web/src/app/opengraph-image.svg` | Social sharing image |
| `apps/web/src/app/timetoleave_dark_logo.svg` | Master SVG with full wordmark |
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+54
View File
@@ -0,0 +1,54 @@
# TimeToLeave — Features Implementation Checklist
## Phase 1 — New Settings Infrastructure (Steps 1-3)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 1 | Extend ReminderSettings type with 3 new fields | [x] | [x] |
| 2 | Update useReminderSettings hook with defaults + setters | [x] | [x] |
| 3 | Update ReminderSettingsPanel UI (slider + 2 toggles) | [x] | [x] |
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 4 | Create WalkRoutingClient (OSRM foot profile) | [x] | [x] |
| 5 | Create /api/walk-route endpoint | [x] | [x] |
| 6 | Add getWalkRoute to api-client package | [x] | [x] |
## Phase 3 — Departure Time Calculation (Steps 7-8)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 7 | Create useDepartureTime hook | [x] | [x] |
| 8 | Update useClock to accept departureTime override | [x] | [x] |
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 9 | Create useWalkRoute hook | [x] | [x] |
| 10 | Create WalkingOption component | [x] | [x] |
| 11 | Update EventCard with mode selector + conditional rendering | [x] | [x] |
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 12 | Update TrainSection props (arrival buffer + walk option) | [x] | [x] |
| 13 | Update JourneyList with arrival buffer filtering | [x] | [x] |
## Phase 6 — Verification & Testing (Steps 14-16)
| # | Step | ✅ | ✔️ |
|---|---|----|----|
| 14 | Integration verification (manual testing) | [ ] | [ ] |
| 15 | Build verification (typecheck, lint, test, build) | [x] | [x] |
| 16 | Update api-client exports | [x] | [x] |
---
**Legend:**
- ✅ = Done (code written)
- ✔️ = Verified (tests/builds pass)
- `[~]` = Optional or deferred (never blocks phase advancement)
+604
View File
@@ -0,0 +1,604 @@
## Phase 1 — New Settings Infrastructure (Steps 1-3)
Extend the `ReminderSettings` type, hook, and UI panel with three new options.
---
#### Step 1: Extend ReminderSettings Type (~5 min)
**File:** `packages/core/src/types.ts`
**Goal:** Add three new fields to `ReminderSettings` for arrival buffer, walking, and bike toggles.
**Change `ReminderSettings` interface:**
```typescript
export interface ReminderSettings {
bufferMinutes: number;
enabled: boolean;
arrivalBufferMinutes: number; // arrive X minutes before event (default 5)
showWalkingOption: boolean; // show walk-from-station option (default true)
showBikeOption: boolean; // show bike route section (default true)
}
```
**Actions:**
1. Add the three new fields to the interface
2. No changes needed to other types
**Acceptance criteria:**
- [ ] `packages/core` compiles without errors
- [ ] `npm run typecheck -w packages/core` passes
---
#### Step 2: Update useReminderSettings Hook (~10 min)
**File:** `apps/web/src/hooks/useReminderSettings.tsx`
**Goal:** Add default values and setters for the three new settings fields.
**Changes:**
Update DEFAULTS:
```typescript
const DEFAULTS: ReminderSettings = {
bufferMinutes: 15,
enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
};
```
Update context interface:
```typescript
interface ReminderContextType extends ReminderSettings {
setBufferMinutes: (minutes: number) => void;
setEnabled: (enabled: boolean) => void;
setArrivalBufferMinutes: (minutes: number) => void;
setShowWalkingOption: (show: boolean) => void;
setShowBikeOption: (show: boolean) => void;
}
```
Add setter implementations:
```typescript
const setArrivalBufferMinutes = useCallback((minutes: number) => {
setSettings((prev) => ({ ...prev, arrivalBufferMinutes: Math.max(0, Math.min(30, minutes)) }));
}, []);
const setShowWalkingOption = useCallback((show: boolean) => {
setSettings((prev) => ({ ...prev, showWalkingOption: show }));
}, []);
const setShowBikeOption = useCallback((show: boolean) => {
setSettings((prev) => ({ ...prev, showBikeOption: show }));
}, []);
```
Update Provider value to include new setters.
**Acceptance criteria:**
- [ ] Hook exports all three new setters
- [ ] localStorage serialization includes new fields
- [ ] `npm run typecheck -w apps/web` passes
---
#### Step 3: Update ReminderSettingsPanel UI (~15 min)
**File:** `apps/web/src/app/ui/ReminderSettingsPanel.tsx`
**Goal:** Add UI controls for the three new settings.
**Add after the buffer minutes slider, before the permission status section:**
```tsx
{/* Arrival buffer slider */}
<div className="space-y-2">
<label
htmlFor="arrival-buffer"
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
>
Arrive early
<span className="text-gray-500 dark:text-gray-400">
(minutes before event)
</span>
</label>
<div className="flex items-center gap-3">
<input
id="arrival-buffer"
type="range"
min={0}
max={30}
step={1}
value={arrivalBufferMinutes}
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
className="flex-1 accent-[#B23CFF]"
/>
<output
htmlFor="arrival-buffer"
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
>
{arrivalBufferMinutes}
</output>
</div>
</div>
{/* Show walking option toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
Show walking option
</span>
<button
role="switch"
aria-checked={showWalkingOption}
onClick={() => setShowWalkingOption(!showWalkingOption)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showWalkingOption ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Show bike option toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
Show bike route
</span>
<button
role="switch"
aria-checked={showBikeOption}
onClick={() => setShowBikeOption(!showBikeOption)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showBikeOption ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
```
**Destructure new values from the hook at the top of the component:**
```typescript
const {
bufferMinutes, enabled, setBufferMinutes, setEnabled,
arrivalBufferMinutes, showWalkingOption, showBikeOption,
setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption,
} = useReminderSettings();
```
**Acceptance criteria:**
- [ ] Panel renders all new controls
- [ ] Slider range is 0-30 for arrival buffer
- [ ] Toggles match existing visual style
- [ ] `npm run build -w apps/web` succeeds
---
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
Add the ability to calculate walking routes using OSRM's foot profile.
---
#### Step 4: Create WalkRoutingClient (~10 min)
**File:** `apps/web/src/lib/walk-routing-client.ts` (create new)
**Goal:** Clone `BikeRoutingClient` but targeting the OSRM foot profile.
**Actions:**
1. Copy `apps/web/src/lib/bike-routing-client.ts` to `walk-routing-client.ts`
2. Rename class to `WalkRoutingClient`
3. Change OSRM path from `/route/v1/bicycle/` to `/route/v1/foot/`
4. Change cache key prefix from `osrm:bike:` to `osrm:walk:`
5. Rename `getBikeRoute` to `getWalkRoute`
**Acceptance criteria:**
- [ ] File compiles without errors
- [ ] Class mirrors BikeRoutingClient structure exactly
- [ ] Cache keys are namespaced separately
---
#### Step 5: Create Walk Route API Endpoint (~10 min)
**File:** `apps/web/src/app/api/walk-route/route.ts` (create new)
**Goal:** Server-side handler that delegates to WalkRoutingClient.
**Actions:**
1. Copy `apps/web/src/app/api/bike-route/route.ts` to `walk-route/route.ts`
2. Import `WalkRoutingClient` instead of `BikeRoutingClient`
3. Update error log messages to reference "Walk route"
**Acceptance criteria:**
- [ ] Endpoint responds at `/api/walk-route`
- [ ] Accepts same query params as bike-route
- [ ] Returns same `BikeRoute` shape (reused type)
---
#### Step 6: Add getWalkRoute to ApiClient (~5 min)
**File:** `packages/api-client/src/client.ts`
**Goal:** Add `getWalkRoute` method mirroring `getBikeRoute`.
**Actions:**
1. Copy `getBikeRoute` method
2. Rename to `getWalkRoute`
3. Change URL from `/api/bike-route` to `/api/walk-route`
4. Export from `packages/api-client/src/index.ts`
**Acceptance criteria:**
- [ ] Method accepts same signature as `getBikeRoute`
- [ ] Returns `Promise<BikeRoute>`
- [ ] `npm run typecheck -w packages/api-client` passes
---
## Phase 3 — Departure Time Calculation (Steps 7-8)
Build the logic for calculating when you should leave based on transport mode.
---
#### Step 7: Create useDepartureTime Hook (~15 min)
**File:** `apps/web/src/hooks/useDepartureTime.ts` (create new)
**Goal:** Calculate the optimal departure time based on selected mode, journeys, bike route, and arrival buffer.
**Actions:**
1. Create hook accepting `eventTime`, `journeys`, `bikeRoute`, `activeMode`
2. Import `useReminderSettings` for `arrivalBufferMinutes`
3. Calculate `targetArrivalTime = eventTime - arrivalBufferMinutes`
4. For train mode: find journeys arriving before target, pick latest departure
5. For bike mode: calculate departure from target minus bike duration
6. Return `{ departureTime, arrivalTime, mode }`
**Key implementation notes:**
- Bike duration from OSRM is in seconds, convert to milliseconds for Date math
- Filter cancelled journeys
- Return null values if no valid option exists for the mode
- Use `useMemo` to avoid recalculating on unrelated renders
**Acceptance criteria:**
- [ ] Hook returns correct departure time for train mode
- [ ] Hook returns correct departure time for bike mode
- [ ] Returns null when no valid journey/route exists
- [ ] Respects arrival buffer setting
---
#### Step 8: Update useClock Hook (~10 min)
**File:** `apps/web/src/hooks/useClock.ts`
**Goal:** Accept optional departure time override so countdown reflects "time to leave" instead of "time to event".
**Changes:**
Update signature:
```typescript
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
```
Update memoized logic:
```typescript
const effectiveTarget = departureTime ?? targetDate;
return useMemo(() => {
const countdown = calculateCountdown(effectiveTarget);
const diffMs = effectiveTarget.getTime() - now.getTime();
// ... rest of status logic using effectiveTarget
}, [targetDate, departureTime, now]);
```
**Acceptance criteria:**
- [ ] When departureTime is provided, countdown is to departure time
- [ ] When departureTime is null/undefined, countdown is to event time (backward compatible)
- [ ] `npm run typecheck -w apps/web` passes
---
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
Wire everything together in the EventCard with a mode selector and conditional rendering.
---
#### Step 9: Add Walk Route Hook (~10 min)
**File:** `apps/web/src/hooks/useWalkRoute.ts` (create new)
**Goal:** Create hook for fetching walk routes, similar to `useBikeRoute`.
**Actions:**
1. Copy `apps/web/src/hooks/useBikeRoute.ts` to `useWalkRoute.ts`
2. Rename state variables from `bikeRoute` to `walkRoute`
3. Call `client.getWalkRoute` instead of `client.getBikeRoute`
4. Return `{ walkRoute, loading, error }`
**Acceptance criteria:**
- [ ] Hook fetches walk route via the walk-route API
- [ ] Follows same pattern as useBikeRoute
- [ ] Compiles without errors
---
#### Step 10: Create WalkingOption Component (~10 min)
**File:** `apps/web/src/app/event/WalkingOption.tsx` (create new)
**Goal:** Show walking duration from the arrival station to the destination.
**Actions:**
1. Create component accepting `walkRoute`, `walkLoading`, `walkError` props
2. Show loading spinner while fetching
3. When route exists, show walk time in minutes and distance in km
4. Use a pedestrian icon (inline SVG or emoji)
5. Return null if no route or error
**Acceptance criteria:**
- [ ] Component renders walk duration badge
- [ ] Matches existing visual style
- [ ] Graceful handling of loading/error states
---
#### Step 11: Update EventCard with Mode Selector (~20 min)
**File:** `apps/web/src/app/event/EventCard.tsx`
**Goal:** Add transport mode selector, wire departure time to countdown, conditionally render sections.
**Changes:**
1. Add mode state:
```typescript
type TransportMode = "train" | "bike";
const [activeMode, setActiveMode] = useState<TransportMode>("train");
```
2. Use settings:
```typescript
const { arrivalBufferMinutes, showWalkingOption, showBikeOption } = useReminderSettings();
```
3. Fetch walk route from destination station to destination:
```typescript
const { walkRoute, loading: walkLoading, error: walkError } = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
```
4. Use departure time hook:
```typescript
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute, activeMode);
```
5. Pass departureTime to useClock:
```typescript
const { countdown, status } = useClock(event.eventTime, departureTime);
```
6. Add mode selector UI between the header info and the sections:
```tsx
<div className="mb-4 flex gap-2">
<button
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
activeMode === "train"
? "bg-[#B23CFF] text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
}`}
onClick={() => setActiveMode("train")}
>
Train
</button>
<button
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
activeMode === "bike"
? "bg-[#B23CFF] text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
}`}
onClick={() => setActiveMode("bike")}
>
Bike
</button>
</div>
```
7. Conditional section rendering:
- TrainSection renders when `activeMode === "train"` (pass arrival buffer + walk option props)
- BikeSection renders when `showBikeOption && activeMode === "bike"`
- WienerLinienSection stays unchanged (always visible)
**Acceptance criteria:**
- [ ] Mode selector toggles between Train and Bike
- [ ] Countdown badge updates to reflect departure time for selected mode
- [ ] Bike section hidden when `showBikeOption` is false
- [ ] Train section receives arrival buffer for filtering
- [ ] Walk route fetched from station to destination
---
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
Pass arrival buffer through the component tree and use it for filtering/countdown.
---
#### Step 12: Update TrainSection Props (~10 min)
**File:** `apps/web/src/app/event/TrainSection.tsx`
**Goal:** Accept and forward new props for walking option and arrival buffer.
**Changes:**
Extend props interface:
```typescript
type TrainSectionProps = {
journeys: Journey[];
eventTime: Date;
destName: string;
loading: boolean;
error?: string | null;
onRefresh?: () => void;
className?: string;
arrivalBufferMinutes?: number;
showWalkingOption?: boolean;
walkRoute?: import("@timetoleave/core").BikeRoute | null;
walkLoading?: boolean;
walkError?: string | null;
};
```
Pass through to JourneyList:
```tsx
<JourneyList
journeys={journeys}
eventTime={eventTime}
arrivalBufferMinutes={arrivalBufferMinutes ?? 0}
/>
```
Add WalkingOption at bottom when enabled:
```tsx
{showWalkingOption && (
<div className="p-4 border-t border-gray-200 dark:border-white/10">
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
</div>
)}
```
**Acceptance criteria:**
- [ ] Props are optional with defaults
- [ ] WalkingOption renders below journey list
- [ ] Arrival buffer forwarded to JourneyList
---
#### Step 13: Update JourneyList with Arrival Buffer (~10 min)
**File:** `apps/web/src/app/event/JourneyList.tsx`
**Goal:** Use arrival buffer when calculating countdown and filtering invalid journeys.
**Changes:**
Update props:
```typescript
type JourneyListProps = {
journeys: Journey[];
eventTime: Date;
arrivalBufferMinutes: number;
className?: string;
};
```
Calculate adjusted target:
```typescript
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
```
Filter out journeys arriving after target:
```typescript
const validJourneys = journeys.filter(j => !j.cancelled);
```
Dim journeys that arrive too late:
```tsx
const arrivesTooLate = j.rA.getTime() > targetArrivalTime.getTime();
```
Apply dimmed styling to late journeys:
```tsx
<li key={j.id} className={`... ${arrivesTooLate ? "opacity-40 line-through" : ""}`}>
```
**Acceptance criteria:**
- [ ] Late journeys are visually dimmed
- [ ] Countdown reflects arrival buffer
- [ ] Late journeys are not removed, just dimmed (user can still see them)
---
## Phase 6 — Verification & Testing (Steps 14-16)
Ensure everything works together correctly.
---
#### Step 14: Integration Verification (~10 min)
**Goal:** Verify the full flow from settings through to the UI.
**Manual test steps:**
1. Open settings, set arrival buffer to 10 minutes
2. Verify countdown badge shows earlier departure time than event time
3. Switch to bike mode, verify countdown updates to bike departure time
4. Enable walking option, verify walk duration appears under train section
5. Toggle bike option off, verify bike section disappears
6. Toggle bike option on, verify bike section reappears
**Acceptance criteria:**
- [ ] All settings persist in localStorage
- [ ] Countdown reflects selected mode and arrival buffer
- [ ] Walk option shows correctly when enabled
- [ ] Bike section toggles correctly
---
#### Step 15: Run Build Verification (~5 min)
**Commands:**
```bash
npm run typecheck -w packages/core
npm run typecheck -w packages/api-client
npm run typecheck -w apps/web
npm run lint -w apps/web
npm run build -w apps/web
npm test
```
**Acceptance criteria:**
- [ ] All typechecks pass with zero errors
- [ ] Lint passes with zero errors
- [ ] Build completes successfully
- [ ] All existing tests still pass
---
#### Step 16: Update api-client exports (~5 min)
**File:** `packages/api-client/src/index.ts`
**Goal:** Ensure `getWalkRoute` is exported from the package.
**Actions:**
1. Check current exports in index.ts
2. Add `getWalkRoute` method to the exported class if not already present
3. Verify the method is accessible from web app imports
**Acceptance criteria:**
- [ ] `getWalkRoute` is importable from `@timetoleave/api-client`
- [ ] No breaking changes to existing exports
---
+216
View File
@@ -0,0 +1,216 @@
# TimeToLeave - Manual Integration Testing Checklist
## Overview
This checklist guides you through manual testing of the TimeToLeave application to ensure all features work correctly in the browser.
## Prerequisites
- [ ] Application is running locally or deployed
- [ ] All required environment variables are set
- [ ] Network connection is available for external API calls
---
## 1. Settings Infrastructure Testing
### Arrival Buffer Settings
- [ ] Navigate to Settings panel
- [ ] Set arrival buffer to 10 minutes
- [ ] Verify buffer value is displayed correctly
- [ ] Test different buffer values (0, 5, 15, 30 minutes)
- [ ] Verify buffer value persists after page refresh
### Walking Option Toggle
- [ ] Enable "Show walking option" toggle
- [ ] Verify toggle state is saved
- [ ] Disable "Show walking option" toggle
- [ ] Verify toggle state persists after page refresh
### Bike Option Toggle
- [ ] Enable "Show bike option" toggle
- [ ] Verify toggle state is saved
- [ ] Disable "Show bike option" toggle
- [ ] Verify toggle state persists after page refresh
---
## 2. Walk Routing Testing
### Walk Route API
- [ ] Open Developer Tools (F12) → Network tab
- [ ] Trigger a walk route calculation (e.g., by loading an event with walk mode)
- [ ] Verify `/api/walk-route` request appears in network log
- [ ] Check request contains correct query parameters (fromLat, fromLng, toLat, toLng)
- [ ] Verify response contains distance, duration, and steps array
- [ ] Test with different coordinate pairs
### Walk Route Display
- [ ] Enable walking option in settings
- [ ] Load an event that should show walk route
- [ ] Verify walk duration appears under train section
- [ ] Verify walk distance is displayed
- [ ] Verify step-by-step instructions are shown
- [ ] Test with events at different locations
---
## 3. Departure Time Calculation Testing
### Countdown Badge
- [ ] Set arrival buffer to 10 minutes
- [ ] Verify countdown badge shows earlier departure time than event time
- [ ] Test with different event times (now, in 1 hour, in 3 hours)
- [ ] Verify countdown updates in real-time
### Departure Time Override
- [ ] Switch between transport modes (train, bike, walk)
- [ ] Verify countdown updates to reflect selected mode
- [ ] Test mode switching multiple times
- [ ] Verify departure time calculation is consistent
---
## 4. Mode Selector Testing
### Transport Mode Selection
- [ ] Verify "Train" mode is selected by default
- [ ] Click "Bike" mode button
- [ ] Verify "Bike" mode is now active
- [ ] Click "Walk" mode button
- [ ] Verify "Walk" mode is now active
- [ ] Test switching between all modes multiple times
### Conditional Rendering
- [ ] With walking option disabled: verify walk section is hidden
- [ ] With walking option enabled: verify walk section appears
- [ ] With bike option disabled: verify bike section is hidden
- [ ] With bike option enabled: verify bike section appears
- [ ] Test all combinations of toggle states
---
## 5. JourneyList Filtering Testing
### Arrival Buffer Filtering
- [ ] Set arrival buffer to 5 minutes
- [ ] Load multiple journeys with different arrival times
- [ ] Verify journeys arriving too late are filtered out
- [ ] Increase arrival buffer to 15 minutes
- [ ] Verify previously filtered journeys now appear
- [ ] Test filtering with real-world journey data
---
## 6. Cross-Feature Integration Testing
### Complete Workflow
- [ ] Open settings and set arrival buffer to 10 minutes
- [ ] Enable walking option
- [ ] Enable bike option
- [ ] Load an event with multiple journey options
- [ ] Verify countdown badge shows earlier departure time
- [ ] Switch to bike mode and verify countdown updates
- [ ] Verify walk duration appears under train section
- [ ] Disable bike option and verify bike section disappears
- [ ] Re-enable bike option and verify bike section reappears
- [ ] Test complete workflow with different events
---
## 7. Edge Cases Testing
### Empty States
- [ ] Test with no walk route available (remote location)
- [ ] Verify appropriate error message is displayed
- [ ] Test with missing coordinates
- [ ] Verify graceful handling of missing data
### Network Errors
- [ ] Disable network connection (offline mode in DevTools)
- [ ] Attempt to load walk route
- [ ] Verify error state is displayed
- [ ] Re-enable network and verify retry works
### Invalid Data
- [ ] Test with invalid coordinate values
- [ ] Test with zero or negative buffer times
- [ ] Verify application handles invalid data gracefully
---
## 8. Accessibility Testing
### Keyboard Navigation
- [ ] Tab through all settings controls
- [ ] Verify all buttons and toggles are keyboard accessible
- [ ] Test mode selector with keyboard only
### Screen Reader Compatibility
- [ ] Use Chrome's accessibility inspector or a screen reader
- [ ] Verify all settings have proper labels
- [ ] Verify all interactive elements are announced correctly
### High Contrast Mode
- [ ] Enable high contrast mode in OS settings
- [ ] Verify all UI elements remain visible and readable
---
## 9. Performance Testing
### Loading Times
- [ ] Measure time to load walk route for nearby location (< 5km)
- [ ] Measure time to load walk route for farther location (10-20km)
- [ ] Verify loading spinner appears during API calls
- [ ] Verify loading spinner disappears when complete
### Memory Usage
- [ ] Open Developer Tools → Memory tab
- [ ] Perform multiple walk route calculations
- [ ] Verify no memory leaks (memory usage should stabilize)
---
## 10. Responsive Design Testing
### Mobile
- [ ] Test on mobile device (iPhone/Android)
- [ ] Verify settings panel is usable on small screens
### Tablet
- [ ] Test on tablet device
- [ ] Verify all controls are properly sized
### Desktop
- [ ] Test on various desktop screen sizes
- [ ] Verify layout does not break
---
## Reporting Issues
When you encounter an issue during testing:
1. Note the exact steps to reproduce
2. Record browser/device information
3. Capture any error messages or console logs
4. Take screenshots if UI is affected
5. Test with latest code after reporting
---
## Sign-Off
- [ ] All required tests passed successfully
- [ ] No critical bugs found
- [ ] Application ready for production deployment
**Tested by:** ________________________
**Date:** ________________________
**Browser/Device:** ________________________
**Build Version:** ________________________
---
## Additional Notes
_Add any observations, workarounds, or special test conditions here._
+16 -6
View File
@@ -1,6 +1,8 @@
# ⏱️ TimeToLeave # ⏱️ TimeToLeave
> **TimeToLeave** is a smart departure planner that tells you exactly when to leave home to catch your public transport for upcoming appointments. It syncs with your personal calendar, checks real-time train/bus departures (HAFAS & WienerLinien), calculates your bike route to the station, and provides a live "Leave Status" based on real-time delays. ![TimeToLeave Logo](apps/web/public/timetoleave_logo.png)
> **TimeToLeave** is a smart departure planner that tells you exactly when to leave home to catch your public transport for upcoming appointments. It syncs with your personal calendar, checks real-time train/bus departures (HAFAS & WienerLinien), and provides a live "Leave Status" based on real-time delays.
![Platform](https://img.shields.io/badge/platform-Web_%26_Mobile-blue) ![Next.js](https://img.shields.io/badge/Next.js-16.2-green) ![React Native](https://img.shields.io/badge/React%20Native-0.81-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue) ![Platform](https://img.shields.io/badge/platform-Web_%26_Mobile-blue) ![Next.js](https://img.shields.io/badge/Next.js-16.2-green) ![React Native](https://img.shields.io/badge/React%20Native-0.81-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)
@@ -62,8 +64,8 @@ This project uses a monorepo setup (npm workspaces) to manage multiple, intercon
### Web Application (`apps/web`) ### Web Application (`apps/web`)
* **Framework:** Next.js 16.2.6 (App Router) * **Framework:** Next.js 16.2.6 (App Router)
* **UI:** React 19.2.4 with Tailwind CSS 4 * **UI:** React 19.2.4 with Tailwind CSS 4
* **State Management:** Zustand (for events and station selection) * **State Management:** React Context (via `EventsProvider` and `ReminderSettingsProvider`)
* **Routing:** Next.js built-in routing for `/add-event`, `/calendar`, and `/event` views. * **Routing:** Next.js built-in routing for `/` (event list) and `/calendar` views.
### Mobile Application (`apps/mobile`) ### Mobile Application (`apps/mobile`)
* **Framework:** React Native 0.81 via Expo 54 * **Framework:** React Native 0.81 via Expo 54
@@ -93,8 +95,16 @@ const api = new ApiClient("http://localhost:3000");
// 1. Sync your calendar // 1. Sync your calendar
const events = await api.fetchCalendar("https://example.com/calendar.ics", 7); const events = await api.fetchCalendar("https://example.com/calendar.ics", 7);
// 2. Search for a station by name // 2. Search for a station via the HAFAS LocMatch endpoint
const stations = await api.searchStation("Wien Mitte"); const stationResult = await api.hafasRequest({
svcReqL: [
{
meth: "LocMatch",
req: { searchTxt: "Wien Mitte", maxMatches: 5 },
},
],
});
const stations = stationResult?.svcReqL?.[0]?.res?.locL ?? [];
// 3. Find journeys between stations for a specific date // 3. Find journeys between stations for a specific date
const journeys = await api.searchJourneys( const journeys = await api.searchJourneys(
@@ -106,7 +116,7 @@ const journeys = await api.searchJourneys(
// 4. Get a bike route from your current location to the station // 4. Get a bike route from your current location to the station
const bikeRoute = await api.getBikeRoute( const bikeRoute = await api.getBikeRoute(
48.2082, 16.3738, // From lat/lng 48.2082, 16.3738, // From lat/lng
stations[0].lat, stations[0].lng // To lat/lng 48.1850, 16.3780 // To lat/lng
); );
``` ```
+17 -14
View File
@@ -1,32 +1,32 @@
# Review Agent Rules # Review Agent Rules
These rules apply when reviewing completed implementation steps on the `rewrite/next` branch. These rules apply when reviewing completed implementation steps on the `main` branch.
## Checklist Tracking ## Checklist Tracking
`CHECKLIST.md` uses three checkbox states: `FEATURES_CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase - `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done - `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement** - `[~]` — optional or deferred; **never blocks phase advancement**
Rules: Rules:
- The ✅ column belongs to the rewrite agent; the ✔️ column is yours. - The ✅ column belongs to the implementation agent; the ✔️ column is yours.
- Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items. - Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items.
- If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items. - If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items.
- After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`. - After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`.
- Before reviewing any item in a new phase, read `CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement. - Before reviewing any item in a new phase, read `FEATURES_CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding. - If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Review Scope ## Review Scope
- Review one phase at a time. Within a phase, review items in the order they appear in `CHECKLIST.md`. - Review one phase at a time. Within a phase, review items in the order they appear in `FEATURES_CHECKLIST.md`.
- For each item, cross-reference the implementation against `REWRITE_PLAN.md` and the quality criteria below. - For each item, cross-reference the implementation against `FEATURES_PLAN.md` and the quality criteria below.
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline. - Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline.
## What to Check ## What to Check
**Correctness** **Correctness**
- The behavior matches the intent described in `REWRITE_PLAN.md` and the checklist item. - The behavior matches the intent described in `FEATURES_PLAN.md` and the checklist item.
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers. - API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
- No regressions are introduced in previously working behavior. - No regressions are introduced in previously working behavior.
@@ -43,10 +43,11 @@ Rules:
- Error handling is explicit and user-facing failures are understandable. - Error handling is explicit and user-facing failures are understandable.
- No generated artifacts, caches, logs, or local environment files are committed. - No generated artifacts, caches, logs, or local environment files are committed.
- Dependencies are unchanged unless necessary and justified. - Dependencies are unchanged unless necessary and justified.
- Package boundaries are respected — shared types in `packages/core`, API wrappers in `packages/api-client`, app code in `apps/web`.
**Scope** **Scope**
- The change is scoped to the checklist item — no unrelated modifications. - The change is scoped to the checklist item — no unrelated modifications.
- Old implementation files (`server/`, `oebb-planner-app/`, `oebb-planner.jsx`) were not removed unless parity is tested and cleanup was explicitly requested. - Existing implementation files were not removed unless parity is tested and cleanup was explicitly requested.
**Accessibility (UI items only)** **Accessibility (UI items only)**
- Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states. - Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
@@ -56,21 +57,23 @@ Rules:
- Run the relevant test and build checks to confirm the implementation passes before marking ✔️: - Run the relevant test and build checks to confirm the implementation passes before marking ✔️:
```bash ```bash
npm run typecheck -w packages/core
npm run typecheck -w packages/api-client
npm run typecheck -w apps/web
npm run lint -w apps/web
npm run build -w apps/web
npm test npm test
npm run build
npm run typecheck
npm run lint
``` ```
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the rewrite agent to fix. - If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the implementation agent to fix.
## Completion Checklist ## Completion Checklist
Before marking a ✔️ box, confirm: Before marking a ✔️ box, confirm:
- The ✅ box for this item is already checked by the rewrite agent. - The ✅ box for this item is already checked by the implementation agent.
- All preceding phase items have both ✅ and ✔️ checked. - All preceding phase items have both ✅ and ✔️ checked.
- The implementation matches the intent in `REWRITE_PLAN.md`. - The implementation matches the intent in `FEATURES_PLAN.md`.
- Tests exist, are meaningful, and pass. - Tests exist, are meaningful, and pass.
- Build and type checks pass. - Build and type checks pass.
- No quality issues from the criteria above remain unresolved. - No quality issues from the criteria above remain unresolved.
+2434
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-179
View File
@@ -1,179 +0,0 @@
from agent_base import main
WRITER_PROMPT = """You are a disciplined TypeScript software engineering agent implementing the Wiener Linien feature on the TimeToLeave project.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and required; blocks the next step
- `[x]` — done
- `[~]` — optional or deferred; never blocks advancement
Rules:
- The ✅ column is yours; the ✔️ column belongs to the review agent.
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
- Implement only that one step. Do not implement any later steps.
- After completing the step, mark its ✅ box by changing `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
- Do not mark the ✔️ column — that belongs to the review agent.
## Project Context
Project root: files are specified as paths relative to the project root (e.g. `src/types/index.ts`).
Stack: Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest.
Feature: Wiener Linien real-time departures via the free OGD Echtzeitdaten REST API (`https://www.wienerlinien.at/ogd_realtime`).
Architecture pattern: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → UI components in `src/app`.
## Work Step By Step
- Start by reading `CHECKLIST.md` to identify the current step, then read the relevant existing files.
- State what the current step requires before making changes.
- Implement one coherent change at a time. Prefer small targeted edits over rewrites.
- Review the diff mentally before submitting — ensure it matches the intended behavior.
- Do not move to the next step. The review agent must mark ✔️ before the next step begins.
## Quality Bar
- Patch the existing project; do not restart from scratch unless the file does not yet exist.
- Use relative file paths only.
- Return full file contents — no partial diffs or placeholders.
- Keep TypeScript strictness intact. Never use `any`; use `unknown` at JSON/API boundaries with explicit type guards.
- Prefer `const` over `let`; never use `var`.
- Use async/await throughout; never mix Promise chains and callbacks.
- Use `import type` for type-only imports.
- Use named exports; avoid default exports in library code.
- Keep server-only code out of client components. API calls to Wiener Linien must go through proxy routes, not directly from the browser.
- Preserve existing working behavior unless the current step explicitly changes it.
- For UI work: semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
- If you cannot produce a valid response matching the schema, emit: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
- Return only JSON matching the writer schema.
"""
REVIEWER_PROMPT = """<|think|>
You are a strict senior TypeScript reviewer embedded in a code-generation loop for the TimeToLeave project.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and required; blocks the next step
- `[x]` — done
- `[~]` — optional or deferred; never blocks advancement
Rules:
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
- Only review steps whose ✅ box is already `[x]`. Do not attempt to review unimplemented steps.
- Before reviewing, confirm that all preceding steps have both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking.
- If the step passes the quality bar below, set verdict to "approve". The orchestrator will mark ✔️ automatically.
- If issues remain, set verdict to "needs_changes". Report the failures with file paths and line numbers.
## Review Scope
- Review one step at a time in the order steps appear in `CHECKLIST.md`.
- Cross-reference the implementation against the step description in `CHECKLIST.md`.
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
## What to Check
**Correctness**
- Behavior matches the intent described in the CHECKLIST step.
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
- No regressions in previously working behavior.
**Tests**
- Tests exist for new code covering the main success path, edge cases, and failure behavior.
- Tests are not weakened or removed just to make the suite pass.
- External services (Wiener Linien API, geolocation, time) are mocked; tests do not depend on live network availability.
**Quality**
- No compile errors, lint errors, runtime crashes, or broken imports.
- TypeScript strictness is intact — no `any` used as a shortcut.
- Server-only code is not imported into client components.
- Wiener Linien API calls go through proxy routes, not directly from the browser.
- Error handling is explicit; user-facing failures are understandable.
- No generated artifacts, caches, logs, or local environment files are committed.
- Dependencies unchanged unless necessary and justified.
**Scope**
- The change is scoped to the current CHECKLIST step — no unrelated modifications.
**Accessibility (UI steps only)**
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
## Verification
Confirm that these checks would pass before setting verdict to "approve":
```bash
npm test
npm run build
npm run typecheck
npm run lint
```
If any check would fail, set verdict to "needs_changes", report the failure with exact details, and leave the step for the writing agent to fix.
## Review priorities
1. build-breaking defects
2. test-breaking defects
3. runtime-breaking defects
4. mismatch with the CHECKLIST step intent
5. missing files, exports, wiring, or integration
6. unsafe behavior
7. incorrect types, null handling, async handling, state management
8. missing edge-case handling
9. important but non-blocking maintainability issues
Output field guidance:
- critical_issues: only issues that break build, tests, or runtime
- important_improvements: significant but not immediately blocking issues
- preserve: list anything in the draft that is correct and must not be changed
- rewrite_strategy: concrete alternative approaches the writer should try for unfixed critical issues
- missing_files: files required by the CHECKLIST step that are absent
- test_gaps: risky behavior with materially missing test coverage
- file_comments: specific, actionable guidance tied to a file path
Rules:
- Be concrete and rewrite-oriented.
- Prefer issues the writer can directly fix in the next pass.
- Do not ask questions.
- Do not praise unless identifying something that must be preserved.
- Assume the writer should patch the current code, not restart from scratch.
- If the draft appears unchanged from a previous attempt for a given issue, escalate that issue to critical and suggest an alternative implementation approach.
- If you have already flagged an issue and it was not fixed, say specifically what is still wrong and why the previous attempt failed.
- Return only JSON matching the review schema.
"""
DESIGN_PROMPT = """<|think|>
You are a disciplined TypeScript architect working inside a coding loop on the TimeToLeave project.
Your job is to produce a concise implementation design for the current CHECKLIST step before coding begins.
Project context:
- Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest
- Architecture: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → `src/app` components
- Feature: Wiener Linien real-time departures via `https://www.wienerlinien.at/ogd_realtime`
- Read `CHECKLIST.md` to determine which step is being designed
Design priorities:
1. file layout — which files to create or modify, and why
2. responsibilities — what each file owns
3. interfaces — types and function signatures
4. integration points — how this step connects to existing code
5. dependencies — imports from existing modules
6. testing plan — what to test and how to mock
7. risks — anything that could break existing behavior
8. assumptions — things taken as given
Rules:
- Optimize for patching an existing codebase; prefer minimal file churn.
- Identify any conflicts with existing code (naming, module structure, API contracts).
- Flag required structural changes separately from new additions.
- If the step can be completed by modifying a single existing file, say so explicitly rather than proposing new files.
- Do not redesign the whole project unless the step requires it.
- Keep the design concrete and implementation-ready.
- Return only JSON matching the design schema.
"""
if __name__ == "__main__":
raise SystemExit(main("TS", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
-298
View File
@@ -1,298 +0,0 @@
import os
from pathlib import Path
# Default workspace to the project root (parent of this script's agent_loop/ dir)
# so the script works without --workspace when run from anywhere.
os.environ.setdefault("AGENT_WORKSPACE", str(Path(__file__).resolve().parent.parent))
os.environ.setdefault("AGENT_TASK", "Implement the next pending step in CHECKLIST.md")
os.environ.setdefault("OLLAMA_API_BASE", "http://100.103.83.12:11435")
os.environ.setdefault("WRITER_MODEL", "qwen3.6:27b-64k")
os.environ.setdefault("REVIEWER_MODEL", "qwen3.6:27b-64k")
os.environ.setdefault("DESIGN_MODEL", "qwen3.6:27b-64k")
os.environ.setdefault("AGENT_MAX_REVIEW_LOOPS", "12")
from agent_base_gemma4 import main
# ---------------------------------------------------------------------------
# TimeToLeave — project-specific agent
#
# Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 · Vitest
# Run: python ttl_agent_gemma4.py --task "..." --workspace <project-root> --write-to-workspace
# ---------------------------------------------------------------------------
WRITER_PROMPT = """You are a software engineering agent implementing features on the TimeToLeave project.
## FILE EXTENSION RULE — CHECK EVERY FILE BEFORE SUBMITTING
- `.tsx` — any file that contains JSX (`<Tag />`, `<div>`, `return (...)` with markup)
- `.ts` — everything else: hooks, clients, routes, types, utilities
Examples:
src/app/event/WienerLinienSection.tsx ← renders JSX → .tsx
src/hooks/useWienerLinien.ts ← no JSX → .ts
src/lib/wienerlinien-client.ts ← no JSX → .ts
src/app/api/wienerlinien/stops/route.ts← no JSX → .ts
Wrong extension = broken build. Verify each path ends in the correct suffix.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and required; blocks the next step
- `[x]` — done
- `[~]` — optional or deferred; never blocks advancement
Rules:
- The ✅ column is yours; the ✔️ column belongs to the review agent.
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
- Implement only that one step. Do not implement any later steps.
- After completing the step, change its ✅ from `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
- Do not mark the ✔️ column — that belongs to the review agent.
## Stack
Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
**CRITICAL:** This Next.js version has breaking changes. Before using any Next.js API (routing,
metadata, image, font, caching), read the relevant guide in `node_modules/next/dist/docs/`.
Heed all deprecation notices. APIs and file conventions may differ from your training data.
## Architecture — Four Layers
Always follow this pattern:
src/lib/<name>-client.ts ← singleton, calls external API, server-only
src/app/api/<name>/route.ts ← proxy: validates input, calls client, returns NextResponse
src/hooks/use<Name>.ts ← hook: calls proxy route, manages loading/error/data
src/app/**/<Name>Section.tsx ← component: receives props or calls hook, renders UI
Reference implementations (read before writing):
- Client: src/lib/bike-routing-client.ts
- Route: src/app/api/bike-route/route.ts
- Hook: src/hooks/useBikeRoute.ts
- Component: src/app/event/BikeSection.tsx
- ApiClient: src/lib/api-service.ts — use ApiClient for caching + retries in new clients
- Constants: src/lib/constants.ts — add env vars here as `process.env.X ?? "default"`
- Types: src/types/index.ts — add all new types here
## Next.js Rules
- Route handlers: `export async function GET(request: NextRequest)` or `POST`. Named exports only.
- Imports: `NextRequest`, `NextResponse` from `"next/server"`.
- `"use client"` goes at the top of a file only when it uses `useState`, `useEffect`, event
handlers, `window`, or `navigator`. Server components and route handlers must never have it.
- Never import `src/lib` clients, `crypto`, or `process.env` secrets into client components.
- Path alias: use `@/` for `src/` (e.g. `import { Foo } from "@/types"`).
## Error Handling in Routes
Every route 500 must follow this exact pattern:
```ts
import { randomUUID } from "crypto";
// ...
} catch (error) {
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] <description>:`, error);
return NextResponse.json(
{ error: "Human-readable message", correlationId: corrId },
{ status: 500 },
);
}
```
Input validation errors return `{ error: "..." }` with status 400 — no correlationId needed.
## TypeScript Rules
- Never use `any`. Use `unknown` at JSON/API boundaries with explicit type guards.
- All new types go in `src/types/index.ts`.
- Use `import type` for type-only imports.
- Named exports everywhere. No default exports in `src/lib` or `src/hooks`.
- `const` over `let`. Never `var`. Async/await throughout — no mixed Promise chains.
## Tailwind CSS v4
- Utility classes directly in JSX only. Never `@apply` in CSS files.
- Dark mode uses the `dark:` variant (toggled via a class on `<html>`).
- Do not modify `tailwind.config.ts` unless strictly necessary.
## Vitest Rules
- Test files: `src/lib/__tests__/`, `src/app/api/__tests__/`, `src/hooks/__tests__/`, `src/app/**/__tests__/`.
- Use `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*` APIs.
- Mock `global.fetch` or use `vi.mock` to intercept HTTP — no live network in tests.
- Mock all external services: any third-party API, geolocation, timers (`vi.useFakeTimers()`).
- Cover: main success path, input validation, error/failure behavior.
- Import the module under test, not internal helpers directly.
## General Rules
- Patch the existing project. Only create new files when the layer does not exist yet.
- Return full file contents — no partial diffs, ellipsis, or placeholders.
- Use relative file paths only.
- Use `npm` (project uses `package-lock.json`).
- Do not add features, abstractions, or cleanup beyond what the current CHECKLIST step requires.
- Preserve existing working behavior unless the step explicitly changes it.
- Do not commit generated files, caches, logs, or `.env` secrets.
- Before finalising the files array, verify every path: does it contain JSX? → `.tsx`. No JSX? → `.ts`.
- If you cannot produce a valid response: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
- Return only JSON matching the writer schema.
"""
REVIEWER_PROMPT = """You are a strict senior code reviewer embedded in a code-generation loop for the TimeToLeave project.
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and required; blocks the next step
- `[x]` — done
- `[~]` — optional or deferred; never blocks advancement
Rules:
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
- Only review steps whose ✅ box is already `[x]`. Do not review unimplemented steps.
- Confirm all preceding steps have both ✅ and ✔️ as `[x]` before reviewing. If not, report what is blocking.
- If the step passes all checks below, set verdict to "approve". The orchestrator marks ✔️ automatically.
- If issues remain, set verdict to "needs_changes" and report failures with file paths and line numbers.
## Review Scope
One CHECKLIST step at a time. Cross-reference against the step description. Report concrete issues
with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
## What to Check
**Correctness**
- Behavior matches the current CHECKLIST step's intent.
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
- No regressions in previously working behavior.
**File extensions**
- `.tsx` for files containing JSX; `.ts` for everything else (routes, hooks, lib, types).
- Flag any component returning JSX saved as `.ts`, or any non-JSX file saved as `.tsx`.
**Next.js conventions**
- Route handlers export named `GET`/`POST` functions with `(request: NextRequest)` signature.
- `"use client"` is present when a component uses `useState`, `useEffect`, event handlers, `window`,
or `navigator`; absent on all other files.
- No server-only imports (`src/lib` clients, `crypto`, `process.env` secrets) in client components.
- Next.js APIs match what is documented in `node_modules/next/dist/docs/` — flag anything that looks
like a training-data artifact from an older Next.js version.
- Path alias `@/` used for `src/` imports.
**Error handling**
- All route 500 errors return `{ error: string, correlationId: string }` using `randomUUID().slice(0, 8)`.
- Input validation errors return `{ error: string }` with status 400.
**Tests**
- Tests exist for the new code covering the main success path, input validation, and failure behavior.
- Only Vitest APIs: `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*`.
- All external services and network calls are mocked — no live network in tests.
- Tests are not weakened or removed just to make the suite pass.
**TypeScript**
- No `any`. `unknown` at API/JSON boundaries with explicit type guards.
- No missing null/undefined checks on values from API responses or array indexing.
- No missing `await`, unhandled rejections, or mixed async styles.
- No missing exports for symbols referenced by other files.
- `import type` used for type-only imports.
**Scope and quality**
- Change is scoped to the current CHECKLIST step only.
- No generated artifacts, caches, logs, or `.env` secrets committed.
- Dependencies unchanged unless necessary and justified.
**Accessibility (UI steps only)**
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
## Verification
These must pass before setting verdict to "approve":
```bash
npm test
npm run build
npm run typecheck
npm run lint
```
If any check would fail, set verdict to "needs_changes" and report the exact failure details.
## Review Priorities
1. Build-breaking defects
2. Test-breaking defects
3. Runtime-breaking defects
4. Mismatch with CHECKLIST step intent
5. Missing files, exports, wiring, or integration
6. Incorrect Next.js APIs or conventions
7. Unsafe behavior, missing null checks, async errors
8. Missing edge-case handling
9. Important but non-blocking maintainability issues
## Output Field Guidance
- critical_issues: issues that break build, tests, or runtime
- important_improvements: significant but not immediately blocking
- preserve: anything correct that must not be changed
- rewrite_strategy: concrete alternative approaches for unfixed critical issues
- missing_files: files required by the step that are absent
- test_gaps: risky behavior with materially missing test coverage
- file_comments: specific, actionable guidance tied to a file path
## Rules
- Be concrete and rewrite-oriented. Prefer issues the writer can fix in the next pass.
- Do not ask questions. Do not praise unless identifying something that must be preserved.
- Assume the writer should patch the current code, not restart from scratch.
- If the draft is unchanged from a previous attempt on a flagged issue, escalate to critical and
suggest a concrete alternative implementation approach.
- If you have already flagged an issue that was not fixed, say specifically what is still wrong
and why the previous attempt failed.
- Return only JSON matching the review schema.
"""
DESIGN_PROMPT = """You are a disciplined architect working inside a coding loop on the TimeToLeave project.
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
Produce a concise implementation design for the current CHECKLIST step before coding begins.
Before designing:
1. Read `CHECKLIST.md` to identify the current step.
2. Read `node_modules/next/dist/docs/` for any Next.js API the step will use — this version differs from training data.
3. Check whether `ApiClient` in `src/lib/api-service.ts` covers the new external service's caching and retry needs before proposing a new client.
4. Check `src/lib/constants.ts` for the env-var pattern before adding new configuration.
Architecture layers (follow the existing pattern):
- `src/lib/<name>-client.ts` — singleton, server-only, wraps external API via ApiClient
- `src/app/api/<name>/route.ts` — proxy route, validates input, calls client, NextResponse
- `src/hooks/use<Name>.ts` — hook, fetches from proxy, manages loading/error/data
- `src/app/**/<Name>Section.tsx` — component, renders UI, `"use client"` where needed
Design priorities:
1. File layout — which files to create or modify (prefer modifying over creating new files)
2. Responsibilities — what each file owns
3. Interfaces — exported types and function signatures
4. Integration points — how this connects to existing code
5. Dependencies — exact imports from existing modules
6. Testing plan — what to test, which services to mock, which Vitest APIs to use
7. Risks — anything that could break existing behavior or violate Next.js conventions
8. Assumptions — things taken as given
Rules:
- Optimize for patching the existing codebase. Prefer minimal file churn.
- Identify conflicts with existing code (naming, module structure, API contracts).
- Flag required structural changes separately from new additions.
- If the step can be completed by modifying a single existing file, say so explicitly.
- Do not redesign unrelated parts of the project.
- Keep the design concrete and immediately usable by the writer.
- Return only JSON matching the design schema.
"""
if __name__ == "__main__":
raise SystemExit(main("TTL", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
+5
View File
@@ -40,3 +40,8 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
.claude/
.idea/
.zed/
agent_loop
+58
View File
@@ -0,0 +1,58 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">TimeToLeave app icon</title>
<desc id="desc">An icon-only TimeToLeave mark: a violet and magenta melting clock on a dark rounded-square background, flowing into a right arrow.</desc>
<defs>
<radialGradient id="iconBg" cx="50%" cy="36%" r="76%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="iconMelt" x1="205" y1="170" x2="840" y2="745" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="40%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="iconGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="9" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
.iconStroke { stroke: url(#iconMelt); stroke-width: 56; stroke-linecap: round; stroke-linejoin: round; }
.iconTick { stroke: #F4F1EA; stroke-width: 16; stroke-linecap: round; opacity: 0.96; }
.iconHand { stroke: #F4F1EA; stroke-width: 22; stroke-linecap: round; }
</style>
</defs>
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
<g filter="url(#iconGlow)">
<!-- Simplified app-icon melting clock -->
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
<path class="iconStroke" d="M821 668 C855 643 890 626 928 615"/>
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
</g>
<!-- Minimal face details for small-size legibility -->
<g>
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>
<line class="iconTick" x1="704" y1="514" x2="737" y2="514"/>
<line class="iconTick" x1="512" y1="649" x2="512" y2="680"/>
<line class="iconTick" x1="388" y1="640" x2="411" y2="617"/>
<line class="iconTick" x1="318" y1="514" x2="351" y2="514"/>
<line class="iconTick" x1="388" y1="388" x2="411" y2="411"/>
</g>
<g>
<line class="iconHand" x1="512" y1="514" x2="512" y2="394"/>
<line class="iconHand" x1="512" y1="514" x2="631" y2="590"/>
<circle cx="512" cy="514" r="29" fill="#F4F1EA"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+89
View File
@@ -0,0 +1,89 @@
import js from "@eslint/js";
import ts from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// We have no .eslintrc, so we must define everything here.
export default ts.config(
{
ignores: ["dist/**"],
},
{
extends: [js.configs.recommended, ts.configs.recommended],
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
// React Native globals
__DEV__: "readonly",
alert: "readonly",
console: "readonly",
document: "readonly",
navigator: "readonly",
window: "readonly",
require: "readonly",
module: "readonly",
exports: "readonly",
process: "readonly",
jest: "readonly",
describe: "readonly",
it: "readonly",
test: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
tsconfigRootDir: path.resolve(__dirname),
},
},
plugins: {
react: reactPlugin,
},
rules: {
...js.configs.recommended.rules,
...ts.configs.recommended.rules,
// TypeScript handles type-related issues
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
// Allow _-prefixed parameters and variables to signal intentionally unused
"no-unused-vars": [
"warn",
{
varsIgnorePattern: "^_",
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
varsIgnorePattern: "^_",
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
// React Native uses require() heavily
"no-var-requires": "off",
// We use React Native's StyleSheet
"react/no-unknown-property": [
"error",
{
ignore: ["flex", "justifyContent", "alignItems", "width", "height", "margin", "padding"],
},
],
// Allow console.log for debugging
"no-console": "off",
},
},
);
+6 -2
View File
@@ -1,6 +1,7 @@
{ {
"name": "@timetoleave/mobile", "name": "@timetoleave/mobile",
"version": "1.0.0", "version": "1.0.0",
"type": "module",
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -8,7 +9,7 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"lint": "echo 'no lint yet'", "lint": "eslint src/",
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
@@ -28,14 +29,17 @@
"react-native-screens": "^4.24.0" "react-native-screens": "^4.24.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/react-native": "^13.3.3", "@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/react": "^19", "@types/react": "^19",
"eslint-plugin-react": "^7.37.5",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "~54.0.0", "jest-expo": "~54.0.0",
"react-test-renderer": "19.2.4", "react-test-renderer": "19.2.4",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"typescript": "~5.9.2" "typescript": "~5.9.2",
"typescript-eslint": "^8.59.3"
}, },
"private": true "private": true
} }
@@ -183,6 +183,9 @@ describe('eventStore', () => {
expect(settings).toEqual({ expect(settings).toEqual({
bufferMinutes: 30, bufferMinutes: 30,
enabled: true, enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
}); });
}); });
@@ -202,6 +205,9 @@ describe('eventStore', () => {
const settings = { const settings = {
bufferMinutes: 45, bufferMinutes: 45,
enabled: false, enabled: false,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
}; };
await saveNotificationSettings(settings); await saveNotificationSettings(settings);
+21 -13
View File
@@ -23,7 +23,7 @@ import type { Event, Journey } from '@timetoleave/core';
describe('notifications service', () => { describe('notifications service', () => {
describe('calculateLeaveByTime', () => { describe('calculateLeaveByTime', () => {
it('should calculate leave-by time from event time minus buffer', () => { it('should calculate leave-by time from event time minus arrival buffer minus reminder buffer', () => {
const event: Event = { const event: Event = {
id: 'test-1', id: 'test-1',
title: 'Test Event', title: 'Test Event',
@@ -32,10 +32,12 @@ describe('notifications service', () => {
source: 'manual', source: 'manual',
}; };
const leaveByTime = calculateLeaveByTime(event, [], 30); const leaveByTime = calculateLeaveByTime(event, [], 30, 5);
// Leave-by time should be 30 minutes before event time // Leave-by time should be 30 minutes before event time
const expectedTime = new Date('2025-01-01T09:30:00Z'); // (arrival buffer) minus 5 minutes reminder buffer
// = event time - 35 minutes total
const expectedTime = new Date('2025-01-01T09:25:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
}); });
@@ -75,10 +77,11 @@ describe('notifications service', () => {
}, },
]; ];
const leaveByTime = calculateLeaveByTime(event, journeys, 30); const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// Should use earliest non-cancelled journey (journey-2 at 07:00) minus buffer // Should use earliest non-cancelled journey (journey-2 at 07:00)
const expectedTime = new Date('2025-01-01T06:30:00Z'); // Leave-by time = journey departure (07:00) - reminder buffer (5 min)
const expectedTime = new Date('2025-01-01T06:55:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
}); });
@@ -118,10 +121,11 @@ describe('notifications service', () => {
}, },
]; ];
const leaveByTime = calculateLeaveByTime(event, journeys, 30); const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// Should use journey-2 since journey-1 is cancelled // Should use journey-2 since journey-1 is cancelled
const expectedTime = new Date('2025-01-01T06:30:00Z'); // Leave-by time = journey departure (07:00) - reminder buffer (5 min)
const expectedTime = new Date('2025-01-01T06:55:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
}); });
@@ -149,10 +153,11 @@ describe('notifications service', () => {
}, },
]; ];
const leaveByTime = calculateLeaveByTime(event, journeys, 30); const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
// All journeys cancelled, fall back to event time minus buffer // All journeys cancelled, fall back to event time minus arrival buffer minus reminder buffer
const expectedTime = new Date('2025-01-01T09:30:00Z'); // = 10:00 - 30 min - 5 min = 09:25
const expectedTime = new Date('2025-01-01T09:25:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime()); expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
}); });
@@ -165,9 +170,12 @@ describe('notifications service', () => {
source: 'manual', source: 'manual',
}; };
const leaveByTime = calculateLeaveByTime(event, [], 0); const leaveByTime = calculateLeaveByTime(event, [], 30, 0);
expect(leaveByTime.getTime()).toBe(event.eventTime.getTime()); // With zero reminder buffer, leave-by time = event time - arrival buffer
// = 10:00 AM - 30 minutes = 09:30 AM
const expectedTime = new Date('2025-01-01T09:30:00Z');
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
}); });
}); });
}); });
+1 -2
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useState } from 'react';
import { import {
Alert,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { import {
Alert,
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
Text, Text,
+106 -23
View File
@@ -29,7 +29,7 @@ type ScreenProps = {
route: RouteProp<RootStack, 'Settings'>; route: RouteProp<RootStack, 'Settings'>;
}; };
export function SettingsScreen({ navigation }: ScreenProps) { export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const [origin, setOrigin] = useState<Station | null>(null); const [origin, setOrigin] = useState<Station | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<Station[]>([]); const [results, setResults] = useState<Station[]>([]);
@@ -37,7 +37,11 @@ export function SettingsScreen({ navigation }: ScreenProps) {
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({ const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
bufferMinutes: 30, bufferMinutes: 30,
enabled: true, enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
}); });
const [showAdvanced, setShowAdvanced] = useState(false);
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -97,29 +101,30 @@ export function SettingsScreen({ navigation }: ScreenProps) {
const userLat = loc.coords.latitude; const userLat = loc.coords.latitude;
const userLng = loc.coords.longitude; const userLng = loc.coords.longitude;
// Search for stations near the user's actual GPS coordinates // Find real public-transport stops near the user's GPS coordinates
// Use Nominatim reverse geocode via the API to find a nearby station // via the WienerLinien nearby-stops proxy.
const geoResults = await api.geocode('Wien', 'at'); const stops = await api.findNearbyStops(userLat, userLng, 2000);
// Find the station closest to the user's actual coordinates if (stops.length === 0) {
if (geoResults.length > 0) { Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
const closest = geoResults.reduce((best: { lat: number; lng: number; display_name: string } | null, candidate) => { return;
if (!best) return candidate;
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
return candDist < bestDist ? candidate : best;
}, null);
if (closest) {
const station: Station = {
name: closest.display_name,
extId: String(closest.lat) + ',' + String(closest.lng),
lat: closest.lat,
lng: closest.lng,
};
selectStation(station);
}
} }
} catch (err) {
// Pick the closest stop to the user's actual position
const closest = stops.reduce((best, candidate) => {
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
return candDist < bestDist ? candidate : best;
}, stops[0]);
// Build a Station with the real stop id as extId — HAFAS can look this up.
const station: Station = {
name: closest.name,
extId: closest.id,
lat: closest.lat,
lng: closest.lng,
};
selectStation(station);
} catch (_err) {
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.'); Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
} }
}; };
@@ -141,6 +146,34 @@ export function SettingsScreen({ navigation }: ScreenProps) {
} }
}; };
const updateArrivalBuffer = async (value: string) => {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes >= 0) {
const updated = { ...notifSettings, arrivalBufferMinutes: minutes };
setNotifSettings(updated);
await saveNotificationSettings(updated);
await rescheduleAllNotifications();
}
};
const toggleWalking = async (value: boolean) => {
const updated = { ...notifSettings, showWalkingOption: value };
setNotifSettings(updated);
await saveNotificationSettings(updated);
await rescheduleAllNotifications();
};
const toggleBike = async (value: boolean) => {
const updated = { ...notifSettings, showBikeOption: value };
setNotifSettings(updated);
await saveNotificationSettings(updated);
await rescheduleAllNotifications();
};
const toggleAdvanced = () => {
setShowAdvanced(!showAdvanced);
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Origin Station */} {/* Origin Station */}
@@ -196,6 +229,46 @@ export function SettingsScreen({ navigation }: ScreenProps) {
<Text style={styles.hint}> <Text style={styles.hint}>
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert. Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
</Text> </Text>
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn]} onPress={toggleAdvanced}>
<Text style={styles.locBtnText}>
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
</Text>
</TouchableOpacity>
{showAdvanced && (
<View style={styles.advancedSection}>
<Text style={styles.settingLabel}>Ankunfts-Puffer (Minuten)</Text>
<TextInput
style={[styles.input, styles.numberInput]}
value={String(notifSettings.arrivalBufferMinutes)}
onChangeText={updateArrivalBuffer}
keyboardType="numeric"
accessibilityLabel="Ankunfts-Puffer in Minuten"
/>
<Text style={styles.hint}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Zu Fuß-Option anzeigen</Text>
<Switch
value={notifSettings.showWalkingOption}
onValueChange={toggleWalking}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
accessibilityLabel="Zu Fuß-Option umschalten"
/>
</View>
<View style={styles.settingRow}>
<Text style={styles.settingLabel}>Fahrrad-Option anzeigen</Text>
<Switch
value={notifSettings.showBikeOption}
onValueChange={toggleBike}
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
accessibilityLabel="Fahrrad-Option umschalten"
/>
</View>
</View>
)}
</View> </View>
</View> </View>
); );
@@ -232,6 +305,16 @@ const styles = StyleSheet.create({
}, },
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' }, locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 }, locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
advancedToggle: {
marginTop: 12,
marginBottom: 12,
},
advancedSection: {
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#e5e5ea',
},
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
settingLabel: { fontSize: 14, color: '#1c1c1e' }, settingLabel: { fontSize: 14, color: '#1c1c1e' },
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 }, hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
+14 -4
View File
@@ -16,25 +16,35 @@ Notifications.setNotificationHandler({
/** /**
* Calculate leave-by time from event time and journey data. * Calculate leave-by time from event time and journey data.
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer. * Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
*
* @param event - The event to calculate leave-by time for
* @param journeys - Journey data for this event
* @param arrivalBufferMinutes - How many minutes before the event to arrive
* @param bufferMinutes - How many minutes before leaving to be reminded
*/ */
export function calculateLeaveByTime( export function calculateLeaveByTime(
event: Event, event: Event,
journeys: Journey[], journeys: Journey[],
arrivalBufferMinutes: number,
bufferMinutes: number bufferMinutes: number
): Date { ): Date {
// If we have journeys, use the earliest non-cancelled real departure // Calculate target arrival time (event time minus arrival buffer)
const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000;
// If we have journeys, use the earliest non-cancelled real departure minus reminder buffer
if (journeys.length > 0) { if (journeys.length > 0) {
const best = journeys const best = journeys
.filter((j) => !j.cancelled) .filter((j) => !j.cancelled)
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0]; .sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
if (best) { if (best) {
// Leave by time = earliest real departure time - reminder buffer
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000); return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
} }
} }
// Fallback: event time minus buffer (no journey data) // Fallback: event time minus arrival buffer minus reminder (no journey data)
return new Date(event.eventTime.getTime() - bufferMinutes * 60 * 1000); return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000);
} }
/** /**
@@ -55,7 +65,7 @@ export async function scheduleNotificationsForEvent(
} }
// Calculate leave-by time (when user should actually leave) // Calculate leave-by time (when user should actually leave)
const leaveByTime = calculateLeaveByTime(event, journeys, settings.bufferMinutes); const leaveByTime = calculateLeaveByTime(event, journeys, settings.arrivalBufferMinutes, settings.bufferMinutes);
// Cancel existing notifications for this event - cancel one by one // Cancel existing notifications for this event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync(); const existing = await Notifications.getAllScheduledNotificationsAsync();
+12 -7
View File
@@ -14,6 +14,9 @@ const NOTIFICATIONS_KEY = '@timetoleave_notifications';
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = { const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
bufferMinutes: 30, bufferMinutes: 30,
enabled: true, enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
}; };
// ── Helpers ─────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────
@@ -36,9 +39,13 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
// Notification scheduling utilities // Notification scheduling utilities
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
async function calculateLeaveByTime(event: Event, bufferMinutes: number): Promise<Date> { async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
// Fallback: event time minus buffer (no journey data) // Calculate target arrival time (event time minus arrival buffer)
return new Date(event.eventTime.getTime() - bufferMinutes * 60 * 1000); const targetArrivalTime = new Date(event.eventTime);
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
// Fallback: event time minus arrival buffer minus buffer (no journey data)
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
} }
async function scheduleEventNotification(event: Event): Promise<void> { async function scheduleEventNotification(event: Event): Promise<void> {
@@ -47,9 +54,7 @@ async function scheduleEventNotification(event: Event): Promise<void> {
return; return;
} }
const leaveByTime = await calculateLeaveByTime(event, settings.bufferMinutes); const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
// Cancel existing notifications for this event - cancel one by one
const existing = await Notifications.getAllScheduledNotificationsAsync(); const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data?.eventId === event.id); const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
for (const notif of toCancel) { for (const notif of toCancel) {
@@ -165,7 +170,7 @@ export async function rescheduleAllNotifications(): Promise<void> {
// Schedule new notifications for each event // Schedule new notifications for each event
for (const event of events) { for (const event of events) {
if (settings.enabled) { if (settings.enabled) {
const leaveByTime = await calculateLeaveByTime(event, settings.bufferMinutes); const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
// Default reminders: 30min, 10min, and at leave-by time // Default reminders: 30min, 10min, and at leave-by time
const defaultReminders = [30, 10, 0]; const defaultReminders = [30, 10, 0];
+9
View File
@@ -1,6 +1,10 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
@@ -14,6 +18,11 @@ const eslintConfig = defineConfig([
"next-env.d.ts", "next-env.d.ts",
]), ]),
{ {
languageOptions: {
parserOptions: {
tsconfigRootDir: path.resolve(__dirname),
},
},
rules: { rules: {
// Allow _-prefixed parameters and variables to signal intentionally unused. // Allow _-prefixed parameters and variables to signal intentionally unused.
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
+2 -1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
allowedDevOrigins: ['100.107.92.66'],
serverExternalPackages: ["node-ical"], serverExternalPackages: ["node-ical"],
env: { env: {
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS, CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
@@ -10,7 +11,7 @@ const nextConfig: NextConfig = {
}; };
// Validate environment variables at build time // Validate environment variables at build time
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production' && !process.env.SKIP_ENV_VALIDATION) {
if (!process.env.CORS_ALLOWED_ORIGINS) { if (!process.env.CORS_ALLOWED_ORIGINS) {
throw new Error('Missing CORS_ALLOWED_ORIGINS environment variable in production'); throw new Error('Missing CORS_ALLOWED_ORIGINS environment variable in production');
} }
@@ -1 +0,0 @@
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3981.5076039999994,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.31673999999975,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":31.295493999999962,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.02209999999991,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2098.29757,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":58.96350099999995,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":91.7685570000001,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":18.296495999999934,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":71.45395499999995,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":21.57249200000001,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":53.28839500000004,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.105226000000016,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":222.98069600000008,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":70.98355300000003,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":23.987784000000147,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":16.915164000000004,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.030644999999936,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.18994099999986,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":91.62786400000005,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.45920799999999,"failed":false}]]}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@timetoleave/web", "name": "@timetoleave/web",
"version": "1.0.0" "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

+94 -38
View File
@@ -1,10 +1,10 @@
import { NextRequest } from 'next/server'; import { NextRequest } from "next/server";
import { vi, describe, it, expect, beforeEach } from 'vitest'; import { vi, describe, it, expect, beforeEach } from "vitest";
import proxy from '../proxy'; import proxy from "../proxy";
// Mock NextResponse to avoid internal routing context issues // Mock NextResponse to avoid internal routing context issues
vi.mock('next/server', async (importOriginal) => { vi.mock("next/server", async (importOriginal) => {
const actual = await importOriginal() const actual = await importOriginal();
// Create a mock constructor class with static methods // Create a mock constructor class with static methods
class MockNextResponse { class MockNextResponse {
@@ -14,81 +14,137 @@ vi.mock('next/server', async (importOriginal) => {
public body: BodyInit | null; public body: BodyInit | null;
static next: () => MockNextResponse; static next: () => MockNextResponse;
static json: () => void; static json: (data: unknown, init?: ResponseInit) => MockNextResponse;
static redirect: () => void; static redirect: () => void;
constructor(_init?: ResponseInit) { constructor(init?: ResponseInit) {
this.headers = new Headers(); this.headers = new Headers();
this.status = _init?.status ?? 200; this.status = init?.status ?? 200;
this.statusText = 'OK'; this.statusText = "OK";
this.body = null; this.body = null;
} }
clone() { return new MockNextResponse(); } clone() {
arrayBuffer() { return new ArrayBuffer(0); } return new MockNextResponse();
blob() { return new Blob(); } }
formData() { return new FormData(); } arrayBuffer() {
json() { return {}; } return new ArrayBuffer(0);
text() { return ''; } }
blob() {
return new Blob();
}
formData() {
return new FormData();
}
json() {
return {};
}
text() {
return "";
}
} }
// Static methods // Static methods
MockNextResponse.next = vi.fn(() => new MockNextResponse()); MockNextResponse.next = vi.fn(() => new MockNextResponse());
MockNextResponse.json = vi.fn(); MockNextResponse.json = vi.fn(
(_data, init) => new MockNextResponse(init),
) as typeof MockNextResponse.json;
MockNextResponse.redirect = vi.fn(); MockNextResponse.redirect = vi.fn();
return { return {
...actual as Record<string, unknown>, ...(actual as Record<string, unknown>),
NextResponse: MockNextResponse, NextResponse: MockNextResponse,
} };
}); });
describe('middleware', () => { describe("middleware", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should bypass non-API requests', () => { it("should bypass non-API requests", () => {
const request = new NextRequest('http://localhost:3000/test', { const request = new NextRequest("http://localhost:3000/test", {
headers: { origin: 'http://localhost:3000' }, headers: { origin: "http://localhost:3000" },
}); });
const response = proxy(request); const response = proxy(request);
expect(response).toBeDefined(); expect(response).toBeDefined();
}); });
it('should handle preflight requests for API routes', () => { it("should handle preflight for allowed origin", () => {
const request = new NextRequest('http://localhost:3000/api/test', { const request = new NextRequest("http://localhost:3000/api/test", {
method: 'OPTIONS', method: "OPTIONS",
headers: { origin: 'http://localhost:3000' }, headers: { origin: "http://localhost:3000" },
}); });
const response = proxy(request); const response = proxy(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT, DELETE, OPTIONS'); "http://localhost:3000",
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization'); );
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400'); expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
expect(response.headers.get('Vary')).toBe('Origin'); "GET, POST, OPTIONS",
);
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
"Content-Type, Authorization",
);
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
expect(response.headers.get("Vary")).toBe("Origin");
}); });
it('should handle regular API requests with cross-origin', () => { it("should reject preflight for disallowed origin", () => {
const request = new NextRequest('http://localhost:3000/api/test', { const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: 'http://different-origin.com' }, method: "OPTIONS",
headers: { origin: "http://evil.example.com" },
});
const response = proxy(request);
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Vary")).toBe("Origin");
});
it("should allow regular API request from allowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://localhost:3000" },
}); });
const response = proxy(request); const response = proxy(request);
expect(response).toBeDefined(); expect(response).toBeDefined();
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
expect(response.headers.get('Vary')).toBe('Origin'); "http://localhost:3000",
);
expect(response.headers.get("Vary")).toBe("Origin");
}); });
it('should handle API requests without Origin header', () => { it("should deny API request from disallowed origin", () => {
const request = new NextRequest('http://localhost:3000/api/test', { const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://different-origin.com" },
});
const response = proxy(request);
expect(response).toBeDefined();
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should handle API requests without Origin header", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {}, headers: {},
}); });
const response = proxy(request); const response = proxy(request);
expect(response).toBeDefined(); expect(response).toBeDefined();
// No CORS leak — should not echo *
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should include rate-limit headers on allowed requests", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {},
});
const response = proxy(request);
expect(response.headers.get("X-RateLimit-Limit")).toBeDefined();
expect(response.headers.get("X-RateLimit-Remaining")).toBeDefined();
}); });
}); });
+12 -11
View File
@@ -80,15 +80,16 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
return ( return (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full ${className}`}> <div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}>
<div className="p-6"> <div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Add Manual Event</h3> <p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p>
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
Event Title Event Title
</label> </label>
<input <input
@@ -97,14 +98,14 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Team Meeting" placeholder="Team Meeting"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="brand-input px-3 py-2"
required required
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="event-destination" htmlFor="event-destination"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
> >
Destination Destination
</label> </label>
@@ -114,13 +115,13 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
value={destination} value={destination}
onChange={(e) => setDestination(e.target.value)} onChange={(e) => setDestination(e.target.value)}
placeholder="Vienna Main Station" placeholder="Vienna Main Station"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="brand-input px-3 py-2"
required required
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="event-date" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
Date Date
</label> </label>
<input <input
@@ -128,12 +129,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
type="date" type="date"
value={eventDate} value={eventDate}
onChange={(e) => setEventDate(e.target.value)} onChange={(e) => setEventDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="brand-input px-3 py-2"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="event-time" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
Time Time
</label> </label>
<input <input
@@ -141,7 +142,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
type="time" type="time"
value={eventTime} value={eventTime}
onChange={(e) => setEventTime(e.target.value)} onChange={(e) => setEventTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="brand-input px-3 py-2"
required required
/> />
</div> </div>
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockGetWalkRoute = vi.fn();
vi.mock("@/lib/walk-routing-client", () => ({
WalkRoutingClient: class MockWalkRoutingClient {
getWalkRoute = mockGetWalkRoute;
},
}));
const { GET } = await import("../walk-route/route");
describe("api/walk-route/route", () => {
beforeEach(() => {
mockGetWalkRoute.mockReset();
});
it("should return error when no required parameters are provided", async () => {
const request = new NextRequest("http://localhost/api/walk-route");
const response = await GET(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data).toEqual({ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" });
});
it("should handle valid walk route request", async () => {
mockGetWalkRoute.mockResolvedValue({
distance: 800,
duration: 600,
steps: [
{ name: "Start", distance: 50, duration: 40, instruction: "Head north" },
{ name: "Main St", distance: 150, duration: 120, instruction: "Turn right" },
],
});
const request = new NextRequest(
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
);
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty("distance");
expect(data).toHaveProperty("duration");
expect(data).toHaveProperty("steps");
});
it("should handle client error", async () => {
mockGetWalkRoute.mockRejectedValue(new Error("Network error"));
const request = new NextRequest(
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
);
const response = await GET(request);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Internal server error");
expect(data.correlationId).toHaveLength(8);
});
it("should handle no route found", async () => {
mockGetWalkRoute.mockResolvedValue(null);
const request = new NextRequest(
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
);
const response = await GET(request);
expect(response.status).toBe(404);
const data = await response.json();
expect(data).toEqual({ error: "No route found" });
});
});
+20 -5
View File
@@ -1,25 +1,40 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { BikeRoutingClient } from "@/lib/bike-routing-client"; import { BikeRoutingClient } from "@/lib/bike-routing-client";
import { validateCoordinate } from "@/lib/api-guards";
// Module-level singleton — cache persists across requests // Module-level singleton — cache persists across requests
const client = new BikeRoutingClient(); const client = new BikeRoutingClient();
/** Maximum valid distance between two points in degrees (sanity check). */
const MAX_COORD_DIFF = 10; // ~1100 km, blocks routing across oceans
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) { const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
return NextResponse.json( return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" }, { error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 }, { status: 400 },
); );
} }
// Sanity check: the two points shouldn't be farther apart than MAX_COORD_DIFF degrees
const dLat = Math.abs(toLat - fromLat);
const dLng = Math.abs(toLng - fromLng);
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
return NextResponse.json(
{ error: "Coordinates too far apart" },
{ status: 400 },
);
}
const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng); const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
if (!route) { if (!route) {
+11 -2
View File
@@ -2,15 +2,24 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils"; import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
import { readBodyWithLimit, MAX_REQUEST_BODY_BYTES } from "@/lib/api-guards";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.text(); const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES);
if (!body) { if (!body || body.trim().length === 0) {
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 }); return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
} }
// Guard: cap at 10 000 chars to prevent OOM from node-ical parsing
if (body.length > 10_000) {
return NextResponse.json(
{ error: "Request body too large (max 10 KB for ICS content)" },
{ status: 413 },
);
}
// Use extractEvents for consistent parsing with cleanLocation() and filtering // Use extractEvents for consistent parsing with cleanLocation() and filtering
const events = extractEvents(body, DEFAULT_DAYS); const events = extractEvents(body, DEFAULT_DAYS);
+69 -3
View File
@@ -2,6 +2,25 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils"; import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
import { isCalendarUrlAllowed } from "@/lib/url-validation";
// Maximum allowed ICS response size: 5 MB
const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024;
// Accepted content types for ICS calendar feeds
const ACCEPTED_CONTENT_TYPES = [
'text/calendar',
'text/plain', // some servers mislabel .ics as text/plain
'application/octet-stream', // fallback for servers that don't set a type
];
function hasAcceptableContentType(contentType: string | null | undefined): boolean {
if (!contentType) {
return false;
}
const lower = contentType.toLowerCase().split(';')[0].trim();
return ACCEPTED_CONTENT_TYPES.some(ct => lower.startsWith(ct));
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -13,18 +32,65 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
} }
// SSRF protection: validate URL
if (!isCalendarUrlAllowed(url)) {
return NextResponse.json({ error: "Calendar URL not allowed" }, { status: 403 });
}
const days = daysParam ? parseInt(daysParam, 10) : DEFAULT_DAYS; const days = daysParam ? parseInt(daysParam, 10) : DEFAULT_DAYS;
// Fetch the ICS content from the provided URL // Fetch the ICS content from the provided URL
// redirect: 'manual' prevents following redirects, which blocks SSRF via
// whitelisted-domain → 3xx → internal-service chains.
const icsResponse = await fetch(url, { const icsResponse = await fetch(url, {
redirect: 'manual',
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
headers: {
'Accept': 'text/calendar, text/plain, */*',
'User-Agent': 'TimeToLeave/2.0',
},
}); });
if (!icsResponse.ok) { // If the server redirects, reject it rather than following blindly.
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: icsResponse.status }); if ([301, 302, 303, 307, 308].includes(icsResponse.status)) {
return NextResponse.json(
{ error: 'Calendar URL redirects are not allowed' },
{ status: 400 },
);
} }
const content = await icsResponse.text(); if (!icsResponse.ok) {
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: icsResponse.status });
}
// Validate content-type
const contentType = icsResponse.headers.get('content-type');
if (!hasAcceptableContentType(contentType)) {
return NextResponse.json(
{ error: 'Calendar response has an unexpected content type' },
{ status: 403 },
);
}
// Enforce response size limit to prevent large-body DoS
const contentLength = icsResponse.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_CALENDAR_RESPONSE_SIZE) {
return NextResponse.json(
{ error: 'Calendar response is too large' },
{ status: 413 },
);
}
// Read body with size guard
const arrayBuffer = await icsResponse.arrayBuffer();
if (arrayBuffer.byteLength > MAX_CALENDAR_RESPONSE_SIZE) {
return NextResponse.json(
{ error: 'Calendar response is too large' },
{ status: 413 },
);
}
const content = new TextDecoder('utf-8').decode(arrayBuffer);
// Use extractEvents for consistent parsing with cleanLocation() and filtering // Use extractEvents for consistent parsing with cleanLocation() and filtering
const events = extractEvents(content, days); const events = extractEvents(content, days);
+26
View File
@@ -5,6 +5,18 @@ import { GeocodingClient } from "@/lib/geocoding-client";
// Module-level singleton — cache persists across requests // Module-level singleton — cache persists across requests
const client = new GeocodingClient(); const client = new GeocodingClient();
/**
* Maximum allowed query length. Station/place names are typically
* < 100 characters. This prevents abuse with extremely long inputs.
*/
const MAX_QUERY_LENGTH = 256;
/**
* Allowed country codes for narrowing the search.
* Two-letter ISO 3166-1 alpha-2 codes.
*/
const ALLOWED_COUNTRY_CODES = /^[a-z]{2}(,[a-z]{2}){0,4}$/;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -15,6 +27,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
} }
if (name.length > MAX_QUERY_LENGTH) {
return NextResponse.json(
{ error: "Query too long (max 256 characters)" },
{ status: 400 },
);
}
if (countrycodes && !ALLOWED_COUNTRY_CODES.test(countrycodes.toLowerCase())) {
return NextResponse.json(
{ error: "Invalid countrycodes (use up to 5 two-letter codes, comma-separated)" },
{ status: 400 },
);
}
const results = await client.geocode(name, countrycodes || undefined); const results = await client.geocode(name, countrycodes || undefined);
if (results.length === 0) { if (results.length === 0) {
+156 -21
View File
@@ -1,36 +1,76 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants"; import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core";
import { readBodyWithLimit } from "@/lib/api-guards";
export async function POST(request: NextRequest) { const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
/**
* Maximum number of characters allowed in the JSON body of a HAFAS POST.
* Keeps the relay surface small — a TripSearch + LocMatch request is ~1 KB.
*/
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
function injectHafasAuth(
body: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
if (!body || typeof body !== "object") {
return {};
}
return {
ver: HAFAS_VER,
lang: HAFAS_LANG,
auth: { type: "AID", aid: HAFAS_AID },
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
...body,
};
}
export async function GET(request: NextRequest) {
try { try {
const body = await request.json(); const { searchParams } = new URL(request.url);
// Validate body shape const from = searchParams.get("from");
if (!body || !Array.isArray(body.svcReqL) || body.svcReqL.length === 0) { const to = searchParams.get("to");
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 }); const date = searchParams.get("date");
if (!from || !to || !date) {
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
} }
const svcReq = body.svcReqL[0]; // Validate extId shape — ÖBB station IDs are purely numeric
const allowedMethods = ["TripSearch", "LocMatch"]; if (!/^\d+$/.test(from) || !/^\d+$/.test(to)) {
return NextResponse.json({ error: "Invalid station ID format (must be numeric)" }, { status: 400 });
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
} }
// Cap TripSearch results at 10 const dateObj = new Date(date);
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) { if (isNaN(dateObj.getTime())) {
svcReq.req.numF = 10; return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
} }
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
const body = injectHafasAuth({
svcReqL: [
{
meth: "TripSearch",
req: {
depLocL: [{ type: "S", extId: from }],
arrLocL: [{ type: "S", extId: to }],
outDate: hafasDate,
outTime: hafasTime,
numF: 5,
},
},
],
});
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
@@ -61,3 +101,98 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 }); return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
} }
} }
export async function POST(request: NextRequest) {
try {
// Body size guard before parsing
const rawBody = await readBodyWithLimit(request, HAFAS_BODY_MAX);
if (!rawBody || rawBody.trim().length === 0) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Validate body shape
if (
!body ||
typeof body !== "object" ||
"svcReqL" in body
? !Array.isArray((body as Record<string, unknown>).svcReqL)
: false
) {
// If svcReqL is missing, the injectHafasAuth will add an empty object —
// so we need to check if the enriched body has it
}
// Inject required HAFAS protocol fields
const hafasBody = injectHafasAuth(body as Record<string, unknown>);
// Validate enriched body
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
const svcReq = hafasBody.svcReqL[0];
const allowedMethods = ["TripSearch", "LocMatch"];
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof (svcReq as Record<string, unknown>).meth !== "string" ||
!allowedMethods.includes((svcReq as Record<string, unknown>).meth as string)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (
(svcReq as Record<string, unknown>).meth === "TripSearch" &&
(svcReq as Record<string, unknown>).req &&
typeof (svcReq as Record<string, unknown>).req === "object" &&
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF
) {
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF = Math.min(
Number(((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF),
10,
);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
const response = await fetch(HAFAS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(hafasBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { WalkRoutingClient } from "@/lib/walk-routing-client";
import { validateCoordinate } from "@/lib/api-guards";
// Module-level singleton — cache persists across requests
const client = new WalkRoutingClient();
/** Maximum valid distance between two points in degrees (sanity check). */
const MAX_COORD_DIFF = 10;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
// Sanity check
const dLat = Math.abs(toLat - fromLat);
const dLng = Math.abs(toLng - fromLng);
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
return NextResponse.json(
{ error: "Coordinates too far apart" },
{ status: 400 },
);
}
const route = await client.getWalkRoute(fromLat, fromLng, toLat, toLng);
if (!route) {
return NextResponse.json({ error: "No route found" }, { status: 404 });
}
return NextResponse.json(route);
} catch (error) {
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Walk route API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
@@ -4,6 +4,12 @@ import { WienerLinienClient } from "@/lib/wienerlinien-client";
const client = new WienerLinienClient(); const client = new WienerLinienClient();
/** Wiener Linien stop IDs can be numeric or in WL:format */
const STOP_ID_RE = /^(?:\d+|WL:\d+)$/;
/** Maximum stop IDs to batch-request in a single call. */
const MAX_STOP_IDS = 10;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const stopIdsList = searchParams.getAll("stopIds"); const stopIdsList = searchParams.getAll("stopIds");
@@ -14,17 +20,16 @@ export async function GET(request: NextRequest) {
const rawIds = stopIdsList.flatMap((param) => param.split(",")); const rawIds = stopIdsList.flatMap((param) => param.split(","));
const validStopIds = rawIds const validStopIds = rawIds
.map((id) => id.trim()) .map((id) => id.trim())
.filter((id) => id.length > 0) .filter((id) => id.length > 0 && STOP_ID_RE.test(id))
.filter((id, index, self) => self.indexOf(id) === index); .filter((id, index, self) => self.indexOf(id) === index)
.slice(0, MAX_STOP_IDS);
if (validStopIds.length === 0) { if (validStopIds.length === 0) {
return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 }); return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 });
} }
const cappedIds = validStopIds.slice(0, 10);
try { try {
const monitorData = await client.getMonitor(cappedIds); const monitorData = await client.getMonitor(validStopIds);
// Flatten nested stops array into a single departures list // Flatten nested stops array into a single departures list
const departures = monitorData.stops.flatMap((s) => s.departures); const departures = monitorData.stops.flatMap((s) => s.departures);
return NextResponse.json({ departures }); return NextResponse.json({ departures });
@@ -1,43 +1,52 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client"; import { WienerLinienClient } from "@/lib/wienerlinien-client";
import { validateCoordinate } from "@/lib/api-guards";
const client = new WienerLinienClient(); const client = new WienerLinienClient();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const latStr = searchParams.get("lat"); const lat = validateCoordinate(searchParams.get("lat"), -90, 90);
const lngStr = searchParams.get("lng"); const lng = validateCoordinate(searchParams.get("lng"), -180, 180);
if (!latStr || !lngStr) { if (lat === null && lng === null) {
return NextResponse.json({ error: "Missing required parameters: lat and lng" }, { status: 400 }); return NextResponse.json({ error: "Missing parameters: lat and lng" }, { status: 400 });
} }
const lat = parseFloat(latStr); if (lat === null) {
const lng = parseFloat(lngStr); const latParam = searchParams.get("lat");
if (latParam === null || latParam === "") {
if (isNaN(lat) || isNaN(lng)) { return NextResponse.json({ error: "Missing parameter: lat" }, { status: 400 });
return NextResponse.json({ error: "Invalid coordinates: lat and lng must be numbers" }, { status: 400 }); } else if (isNaN(parseFloat(latParam))) {
return NextResponse.json({ error: "Invalid parameter: lat (must be numbers)" }, { status: 400 });
} else {
return NextResponse.json({ error: "Invalid parameter: lat (latitude out of bounds)" }, { status: 400 });
}
} }
if (lat < -90 || lat > 90) { if (lng === null) {
return NextResponse.json({ error: "Invalid latitude: must be between -90 and 90" }, { status: 400 }); const lngParam = searchParams.get("lng");
} if (lngParam === null || lngParam === "") {
return NextResponse.json({ error: "Missing parameter: lng" }, { status: 400 });
if (lng < -180 || lng > 180) { } else if (isNaN(parseFloat(lngParam))) {
return NextResponse.json({ error: "Invalid longitude: must be between -180 and 180" }, { status: 400 }); return NextResponse.json({ error: "Invalid parameter: lng (must be numbers)" }, { status: 400 });
} else {
return NextResponse.json({ error: "Invalid parameter: lng (longitude out of bounds)" }, { status: 400 });
}
} }
let radius = 1000; let radius = 1000;
const radiusStr = searchParams.get("radius"); const radiusStr = searchParams.get("radius");
if (radiusStr !== null) { if (radiusStr !== null) {
const parsed = parseFloat(radiusStr); const parsed = parseFloat(radiusStr);
if (!isNaN(parsed)) { if (!isNaN(parsed) && parsed > 0) {
radius = parsed; radius = parsed;
} }
} }
// Cap radius at 5000 m
if (radius > 5000) { if (radius > 5000) {
radius = 5000; radius = 5000;
} }
+7 -7
View File
@@ -31,24 +31,24 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
}; };
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}> <div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-white/10 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Import Calendar</h3> <h3 className="text-lg font-semibold text-white">Import Calendar</h3>
</div> </div>
</div> </div>
<div className="p-4"> <div className="p-4">
<div className="border-b border-gray-200 dark:border-gray-700 mb-4"> <div className="mb-4">
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> <nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
<button <button
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "url" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`} className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "url" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
onClick={() => setActiveTab("url")} onClick={() => setActiveTab("url")}
disabled={loading} disabled={loading}
> >
URL URL
</button> </button>
<button <button
className={`pb-2 px-1 border-b-2 font-medium text-sm ${activeTab === "file" ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}`} className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "file" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
onClick={() => setActiveTab("file")} onClick={() => setActiveTab("file")}
disabled={loading} disabled={loading}
> >
+15 -15
View File
@@ -30,19 +30,19 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
const renderHeader = () => { const renderHeader = () => {
return ( return (
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<button <button
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))} onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
> >
&larr; &lt;
</button> </button>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{format(currentDate, "MMMM yyyy")}</h2> <h2 className="text-xl font-semibold text-white">{format(currentDate, "MMMM yyyy")}</h2>
<button <button
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))} onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
> >
&rarr; &gt;
</button> </button>
</div> </div>
); );
@@ -54,7 +54,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
headers.push( headers.push(
<div key={i} className="text-center font-medium text-gray-600 dark:text-gray-300 py-2"> <div key={i} className="py-2 text-center text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/46">
{daysOfWeek[i]} {daysOfWeek[i]}
</div>, </div>,
); );
@@ -87,16 +87,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
role="button" role="button"
tabIndex={0} tabIndex={0}
className={` className={`
min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer min-h-24 cursor-pointer rounded-xl border p-2
${!isCurrentMonth ? "bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500" : "bg-white dark:bg-gray-900"} ${!isCurrentMonth ? "border-white/[0.06] bg-black/18 text-[#F4F1EA]/28" : "border-white/10 bg-white/[0.045] text-[#F4F1EA]"}
${isTodayDate ? "ring-2 ring-blue-500" : ""} ${isTodayDate ? "ring-2 ring-[#D946EF]/80" : ""}
hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors transition-colors hover:border-[#D946EF]/42 hover:bg-white/[0.075]
`} `}
> >
<div className="text-right"> <div className="text-right">
<span <span
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${ className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
isTodayDate ? "bg-blue-500 text-white" : "" isTodayDate ? "bg-gradient-to-br from-[#8B5CF6] to-[#FF2D8D] text-white" : ""
}`} }`}
> >
{format(day, "d")} {format(day, "d")}
@@ -106,13 +106,13 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
{dayEvents.slice(0, 3).map((event) => ( {dayEvents.slice(0, 3).map((event) => (
<div <div
key={event.id} key={event.id}
className="text-xs truncate bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded" className="truncate rounded bg-[#B23CFF]/22 px-2 py-1 text-xs font-medium text-[#F4F1EA]"
> >
{event.title} {event.title}
</div> </div>
))} ))}
{dayEvents.length > 3 && ( {dayEvents.length > 3 && (
<div className="text-xs text-gray-500 dark:text-gray-400">+{dayEvents.length - 3} more</div> <div className="text-xs text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
)} )}
</div> </div>
</div>, </div>,
@@ -133,7 +133,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
}; };
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 ${className}`}> <div className={`brand-panel rounded-2xl p-4 ${className}`}>
{renderHeader()} {renderHeader()}
{renderDays()} {renderDays()}
{renderCells()} {renderCells()}
+2 -2
View File
@@ -24,7 +24,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
if (filteredEvents.length === 0) { if (filteredEvents.length === 0) {
return ( return (
<div className={`text-center py-8 text-gray-500 dark:text-gray-400 ${className}`}> <div className={`brand-panel rounded-2xl px-4 py-8 text-center text-[#F4F1EA]/60 ${className}`}>
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p> <p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
</div> </div>
); );
@@ -32,7 +32,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
return ( return (
<div className={`space-y-4 ${className}`}> <div className={`space-y-4 ${className}`}>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3> <h3 className="text-lg font-semibold text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
<div className="space-y-3"> <div className="space-y-3">
{filteredEvents.map((event) => ( {filteredEvents.map((event) => (
<EventCard key={event.id} event={event} originStation={originStation} /> <EventCard key={event.id} event={event} originStation={originStation} />
+3 -3
View File
@@ -62,9 +62,9 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
}; };
return ( return (
<div className={`p-4 ${className}`}> <div className={`p-1 ${className}`}>
<div <div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${dragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`} className={`mb-4 cursor-pointer rounded-2xl border border-dashed p-8 text-center text-sm transition-colors ${dragging ? "border-[#D946EF] bg-[#B23CFF]/12 text-white" : "border-white/20 bg-white/[0.04] text-[#F4F1EA]/68 hover:border-[#D946EF]/50 hover:bg-white/[0.06]"}`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -74,7 +74,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" /> <input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>} {loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
</div> </div>
{error && <div className="mt-4 text-red-600 text-sm">{error}</div>} {error && <div className="mb-4 text-sm text-[#FF2D8D]">{error}</div>}
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}> <Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
{loading ? <>Select File</> : <>Select File</>} {loading ? <>Select File</> : <>Select File</>}
</Button> </Button>
+4 -4
View File
@@ -21,10 +21,10 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
}; };
return ( return (
<div className={`p-4 ${className}`}> <div className={`p-1 ${className}`}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="calendar-url" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
Calendar URL Calendar URL
</label> </label>
<input <input
@@ -33,12 +33,12 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/calendar.ics" placeholder="https://example.com/calendar.ics"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" className="brand-input px-3 py-2"
required required
/> />
</div> </div>
{error && ( {error && (
<div role="alert" className="text-red-600 text-sm"> <div role="alert" className="text-[#FF2D8D] text-sm">
{error} {error}
</div> </div>
)} )}
+5 -4
View File
@@ -13,10 +13,11 @@ export default function CalendarPage() {
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date()); const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
return ( return (
<div className="max-w-6xl mx-auto p-4"> <div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
<div className="mb-6"> <div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Calendar</h1> <p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Calendar sync</p>
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p> <h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1>
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
</div> </div>
<div className="mb-6"> <div className="mb-6">
+27 -14
View File
@@ -11,19 +11,28 @@ type BikeSectionProps = {
bikeError: string | null | undefined; bikeError: string | null | undefined;
onRefresh?: () => void; onRefresh?: () => void;
className?: string; className?: string;
forceVisible?: boolean;
}; };
const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => { const BikeSection: React.FC<BikeSectionProps> = ({
if (!bikeRoute && !bikeLoading && !bikeError) { bikeRoute,
bikeLoading,
bikeError,
onRefresh,
className = "",
forceVisible = false,
}) => {
if (!forceVisible && !bikeRoute && !bikeLoading && !bikeError) {
return null; return null;
} }
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden mt-4 ${className}`}> <div className={`mt-4 overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-white/10 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Bicycle Route</h3> <h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
<p className="text-sm text-brand-light/60">Door-to-door route to the event</p>
</div> </div>
{onRefresh && ( {onRefresh && (
<Button variant="secondary" size="sm" onClick={onRefresh}> <Button variant="secondary" size="sm" onClick={onRefresh}>
@@ -38,36 +47,40 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
<LoadingSpinner size="md" /> <LoadingSpinner size="md" />
</div> </div>
) : bikeError ? ( ) : bikeError ? (
<div className="text-center py-4 text-red-600">Error: {bikeError}</div> <div className="text-center py-4 text-brand-pink">Error: {bikeError}</div>
) : bikeRoute ? ( ) : bikeRoute ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-300">Distance</span> <span className="text-brand-light/66">Distance</span>
<span className="font-medium"> <span className="font-medium text-white">
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m) {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-300">Duration</span> <span className="text-brand-light/66">Duration</span>
<span className="font-medium"> <span className="font-medium text-white">
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
</span> </span>
</div> </div>
{bikeRoute.steps && bikeRoute.steps.length > 0 && ( {bikeRoute.steps && bikeRoute.steps.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<h4 className="font-medium text-gray-800 dark:text-white mb-2">Steps</h4> <h4 className="mb-2 font-medium text-brand-light">Steps</h4>
<ul className="space-y-2"> <ul className="space-y-2">
{bikeRoute.steps.map((step, index) => ( {bikeRoute.steps.map((step, index) => (
<li key={index} className="text-sm"> <li key={index} className="text-sm">
<span className="font-medium text-blue-600 dark:text-blue-400">{step.name}</span> <span className="font-medium text-brand-fuchsia">{step.name}</span>
<span className="ml-2 text-gray-600 dark:text-gray-300">{step.instruction}</span> <span className="ml-2 text-brand-light/70">{step.instruction}</span>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
</div> </div>
) : null} ) : (
<div className="py-4 text-center text-sm text-brand-light/58">
Add origin and destination coordinates to calculate a bike route.
</div>
)}
</div> </div>
</div> </div>
); );
+120 -16
View File
@@ -1,11 +1,15 @@
"use client"; "use client";
import { useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { useJourneys } from "@/hooks/useJourneys"; import { useJourneys } from "@/hooks/useJourneys";
import { useDestinationStation } from "@/hooks/useDestinationStation"; import { useDestinationStation } from "@/hooks/useDestinationStation";
import { useBikeRoute } from "@/hooks/useBikeRoute"; import { useBikeRoute } from "@/hooks/useBikeRoute";
import { useWalkRoute } from "@/hooks/useWalkRoute";
import { useGeocode } from "@/hooks/useGeocode"; import { useGeocode } from "@/hooks/useGeocode";
import { useClock } from "@/hooks/useClock"; import { useClock } from "@/hooks/useClock";
import { useDepartureTime } from "@/hooks/useDepartureTime";
import { useReminderSettings } from "@/hooks/useReminderSettings";
import { useWienerLinien } from "@/hooks/useWienerLinien"; import { useWienerLinien } from "@/hooks/useWienerLinien";
import type { Event, Station } from "@timetoleave/core"; import type { Event, Station } from "@timetoleave/core";
import TrainSection from "./TrainSection"; import TrainSection from "./TrainSection";
@@ -35,6 +39,17 @@ export default function EventCard({ event, originStation }: EventCardProps) {
error: bikeError, error: bikeError,
} = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng); } = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
const {
walkRoute,
loading: walkLoading,
error: walkError,
} = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
const { const {
stops, stops,
departures, departures,
@@ -42,32 +57,121 @@ export default function EventCard({ event, originStation }: EventCardProps) {
error: wlError, error: wlError,
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng); } = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const { countdown, status } = useClock(event.eventTime); const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
type TransportMode = "train" | "bike";
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
const activeMode: TransportMode = !showBikeOption && requestedMode === "bike" ? "train" : requestedMode;
const { departureTime, arrivalTime, mode: calculatedMode } = useDepartureTime(
event.eventTime,
journeys,
bikeRoute?.duration ?? null,
activeMode,
);
const { countdown, status } = useClock(event.eventTime, departureTime);
const bikeDisabled = !showBikeOption;
const modeOptions: Array<{ id: TransportMode; label: string; meta: string; disabled?: boolean }> = [
{ id: "train", label: "Train", meta: showWalkingOption ? "Rail + final walk" : "Rail only" },
{
id: "bike",
label: "Bike",
meta: bikeDisabled ? "Disabled in settings" : bikeLoading ? "Calculating route" : "Door to door",
disabled: bikeDisabled,
},
];
return ( return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800"> <div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
<div className="mb-3 flex items-center justify-between"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{event.title}</h3> <div>
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p>
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
</div>
<CountdownBadge countdown={countdown} status={status} /> <CountdownBadge countdown={countdown} status={status} />
</div> </div>
<div className="mb-2 text-sm text-gray-600 dark:text-gray-300"> <div className="mb-5 grid gap-3 sm:grid-cols-2">
<span className="font-medium">Destination:</span> {event.destination} <div className="brand-panel-soft rounded-xl p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Destination</p>
<p className="mt-1 text-sm font-medium text-brand-light">{event.destination}</p>
</div>
<div className="brand-panel-soft rounded-xl p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Appointment</p>
<p className="mt-1 text-sm font-medium text-brand-light">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
</div>
</div> </div>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300">
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")} <div className="mb-4 grid gap-3 sm:grid-cols-2">
{modeOptions.map((option) => (
<button
key={option.id}
className={`rounded-xl border p-3 text-left transition-all ${
activeMode === option.id
? "border-brand-fuchsia/70 bg-brand-purple/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
: "border-white/10 bg-white/[0.045] hover:border-brand-fuchsia/40 hover:bg-white/[0.07]"
} ${option.disabled ? "cursor-not-allowed opacity-45 hover:border-white/10 hover:bg-white/[0.045]" : ""}`}
onClick={() => {
if (!option.disabled) {
setRequestedMode(option.id);
}
}}
disabled={option.disabled}
>
<span className="flex items-center justify-between gap-3">
<span className="font-semibold text-white">{option.label}</span>
{activeMode === option.id && (
<span className="rounded-full bg-brand-pink/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
Active
</span>
)}
</span>
<span className="mt-1 block text-xs text-brand-light/58">{option.meta}</span>
</button>
))}
</div>
<div className="mb-4 grid gap-3 rounded-xl border border-white/10 bg-black/16 p-3 text-sm sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Leave by</p>
<p className="mt-1 font-semibold text-white">
{departureTime ? format(departureTime, "HH:mm") : "Pending"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Arrive by</p>
<p className="mt-1 font-semibold text-white">
{arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Buffer</p>
<p className="mt-1 font-semibold text-white">
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
</p>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<TrainSection {activeMode === "train" && (
journeys={journeys} <TrainSection
eventTime={event.eventTime} journeys={journeys}
destName={event.destination} eventTime={event.eventTime}
loading={journeysLoading} destName={event.destination}
error={journeysError} loading={journeysLoading}
/> error={journeysError}
arrivalBufferMinutes={arrivalBufferMinutes}
showWalkingOption={showWalkingOption}
walkRoute={walkRoute}
walkLoading={walkLoading}
walkError={walkError}
/>
)}
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} /> {showBikeOption && activeMode === "bike" && (
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} forceVisible />
)}
{stops.length > 0 && ( {stops.length > 0 && (
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} /> <WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
+53 -26
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { Journey } from "@timetoleave/core"; import type { Journey } from "@timetoleave/core";
import { formatTime } from "@timetoleave/core"; import { formatTime } from "@timetoleave/core";
import LeaveByBadge from "./LeaveByBadge"; import LeaveByBadge from "./LeaveByBadge";
import { calculateCountdown } from "@timetoleave/core"; import { calculateCountdown } from "@timetoleave/core";
@@ -9,40 +9,67 @@ import { calculateCountdown } from "@timetoleave/core";
type JourneyListProps = { type JourneyListProps = {
journeys: Journey[]; journeys: Journey[];
eventTime: Date; eventTime: Date;
arrivalBufferMinutes: number;
className?: string; className?: string;
}; };
const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, className = "" }) => { const JourneyList: React.FC<JourneyListProps> = ({
journeys,
eventTime,
arrivalBufferMinutes,
className = "",
}) => {
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
if (journeys.length === 0) { if (journeys.length === 0) {
return <div className={`text-center py-8 text-gray-500 ${className}`}>No journeys found</div>; return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
} }
return ( return (
<ul className={`space-y-3 ${className}`}> <ul className={`space-y-3 p-4 ${className}`}>
{journeys.map((journey) => ( {journeys.map((journey) => {
<li key={journey.id} className="border-b border-gray-200 pb-3 last:border-b-0 last:pb-0"> const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime();
<div className="flex items-center justify-between mb-2"> const departure = journey.rD ?? journey.sD;
<div className="flex-1"> const arrival = journey.rA ?? journey.sA;
<span className="font-medium">{formatTime(journey.sD)}</span>
<span className="ml-2 text-sm text-gray-500">{journey.platform}</span> return (
{journey.delay > 0 && ( <li
<span className="ml-2 text-sm font-medium text-orange-600">{`+${journey.delay}'`}</span> key={journey.id}
className={`rounded-xl border p-3 ${
arrivesTooLate || journey.cancelled
? "border-brand-pink/18 bg-brand-pink/7 opacity-40 line-through"
: "border-white/10 bg-white/[0.04]"
}`}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex-1">
<span className="font-semibold text-white">{formatTime(departure)}</span>
<span className="ml-2 text-sm text-brand-light/48">{journey.platform}</span>
{journey.delay > 0 && (
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
)}
</div>
<div className="flex-1 text-right">
<span className={`font-medium ${journey.cancelled ? "text-brand-pink" : "text-brand-light"}`}>
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")}
</span>
</div>
<div className="flex-1 text-right">
<LeaveByBadge countdown={calculateCountdown(departure)} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-brand-light/64">
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
<span>Arrives {formatTime(arrival)}</span>
{arrivesTooLate && (
<span className="font-medium text-brand-pink">
misses {arrivalBufferMinutes} min buffer
</span>
)} )}
</div> </div>
<div className="flex-1 text-right"> </li>
<span className={`font-medium ${journey.cancelled ? "text-red-600" : "text-gray-800"}`}> );
{journey.cancelled ? "Cancelled" : journey.trains.join(" → ")} })}
</span>
</div>
<div className="flex-1 text-right">
<LeaveByBadge countdown={calculateCountdown(eventTime)} />
</div>
</div>
<div className="text-sm text-gray-600">
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
</div>
</li>
))}
</ul> </ul>
); );
}; };
+5 -5
View File
@@ -5,11 +5,11 @@ import { CountdownInfo } from "@timetoleave/core";
import Chip from "@/app/ui/Chip"; import Chip from "@/app/ui/Chip";
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
red: "text-red-600", red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100",
orange: "text-orange-600", orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
yellow: "text-yellow-600", yellow: "border-yellow-300/30 bg-yellow-300/16 text-yellow-100",
green: "text-green-600", green: "border-emerald-300/30 bg-emerald-400/16 text-emerald-100",
blue: "text-blue-600", blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
}; };
type LeaveByBadgeProps = { type LeaveByBadgeProps = {
+30 -7
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { Journey } from "@timetoleave/core"; import type { Journey, WalkRoute } from "@timetoleave/core";
import { formatDateTime } from "@timetoleave/core"; import { formatDateTime } from "@timetoleave/core";
import WalkingOption from "./WalkingOption";
import JourneyList from "./JourneyList"; import JourneyList from "./JourneyList";
import LoadingSpinner from "@/app/ui/LoadingSpinner"; import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
@@ -15,6 +16,11 @@ type TrainSectionProps = {
error?: string | null; error?: string | null;
onRefresh?: () => void; onRefresh?: () => void;
className?: string; className?: string;
arrivalBufferMinutes?: number;
showWalkingOption?: boolean;
walkRoute?: WalkRoute | null;
walkLoading?: boolean;
walkError?: string | null;
}; };
const TrainSection: React.FC<TrainSectionProps> = ({ const TrainSection: React.FC<TrainSectionProps> = ({
@@ -25,16 +31,26 @@ const TrainSection: React.FC<TrainSectionProps> = ({
error, error,
onRefresh, onRefresh,
className = "", className = "",
arrivalBufferMinutes,
showWalkingOption,
walkRoute,
walkLoading,
walkError,
}) => { }) => {
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}> <div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-white/10 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Trains</h3> <h3 className="text-lg font-semibold text-white">Trains</h3>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-brand-light/66">
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span> To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
</p> </p>
{(arrivalBufferMinutes ?? 0) > 0 && (
<p className="mt-1 text-xs text-brand-fuchsia">
Target arrival: {arrivalBufferMinutes} min early
</p>
)}
</div> </div>
{onRefresh && ( {onRefresh && (
<Button variant="secondary" size="sm" onClick={onRefresh}> <Button variant="secondary" size="sm" onClick={onRefresh}>
@@ -49,9 +65,16 @@ const TrainSection: React.FC<TrainSectionProps> = ({
<LoadingSpinner size="lg" /> <LoadingSpinner size="lg" />
</div> </div>
) : error ? ( ) : error ? (
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div> <div className="p-8 text-center text-sm text-brand-pink">{error}</div>
) : ( ) : (
<JourneyList journeys={journeys} eventTime={eventTime} /> <>
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
{showWalkingOption && (walkRoute || walkLoading || walkError) && (
<div className="border-t border-white/10 p-4">
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { WalkRoute } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Button from "@/app/ui/Button";
type WalkingOptionProps = {
walkRoute: WalkRoute | null | undefined;
walkLoading?: boolean;
walkError?: string | null | undefined;
onRefresh?: () => void;
className?: string;
};
const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, walkError, onRefresh, className = "" }) => {
if (!walkRoute && !walkLoading && !walkError) {
return null;
}
return (
<div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
<div className="border-b border-white/10 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Final Walk</h3>
<p className="text-sm text-brand-light/60">From arrival station to destination</p>
</div>
{onRefresh && (
<Button variant="secondary" size="sm" onClick={onRefresh}>
Refresh
</Button>
)}
</div>
</div>
<div className="p-4">
{walkLoading ? (
<div className="py-4 text-center">
<LoadingSpinner size="md" />
</div>
) : walkError ? (
<div className="py-4 text-center text-brand-pink">Error: {walkError}</div>
) : walkRoute ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-brand-light/66">Distance</span>
<span className="font-medium text-white">
{Math.round(walkRoute.distance / 1000)} km ({Math.round(walkRoute.distance)} m)
</span>
</div>
<div className="flex justify-between">
<span className="text-brand-light/66">Duration</span>
<span className="font-medium text-white">
{Math.floor(walkRoute.duration / 60)} min {walkRoute.duration % 60} s
</span>
</div>
{walkRoute.steps && walkRoute.steps.length > 0 && (
<div className="mt-4">
<h4 className="mb-2 font-medium text-brand-light">Steps</h4>
<ul className="space-y-2">
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
<li key={index} className="text-sm">
<span className="font-medium text-brand-fuchsia">{step.name}</span>
<span className="ml-2 text-brand-light/70">{step.instruction}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : null}
</div>
</div>
);
};
export default WalkingOption;
@@ -30,7 +30,7 @@ export default function WienerLinienSection({
return ( return (
<div className={className}> <div className={className}>
<LoadingSpinner /> <LoadingSpinner />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading nearby stops...</p> <p className="mt-2 text-sm text-[#F4F1EA]/60">Loading nearby stops...</p>
</div> </div>
); );
} }
@@ -38,7 +38,7 @@ export default function WienerLinienSection({
if (error) { if (error) {
return ( return (
<div className={className}> <div className={className}>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p> <p className="text-sm text-[#FF2D8D]">{error}</p>
</div> </div>
); );
} }
@@ -46,7 +46,7 @@ export default function WienerLinienSection({
if (stops.length === 0) { if (stops.length === 0) {
return ( return (
<div className={className}> <div className={className}>
<p className="text-sm text-gray-500 dark:text-gray-400">No nearby stops found.</p> <p className="text-sm text-[#F4F1EA]/60">No nearby stops found.</p>
</div> </div>
); );
} }
@@ -65,18 +65,18 @@ export default function WienerLinienSection({
const stopDepartures = departuresByStop.get(stop.id) ?? []; const stopDepartures = departuresByStop.get(stop.id) ?? [];
return ( return (
<div key={stop.id} className="mb-4 last:mb-0"> <div key={stop.id} className="mb-4 rounded-xl border border-white/10 bg-black/16 p-4 last:mb-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{stop.name}</h3> <h3 className="text-sm font-semibold text-white">{stop.name}</h3>
{stopDepartures.length === 0 ? ( {stopDepartures.length === 0 ? (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No departures available</p> <p className="mt-1 text-xs text-[#F4F1EA]/40">No departures available</p>
) : ( ) : (
<ul className="mt-1 space-y-1"> <ul className="mt-1 space-y-1">
{stopDepartures.map((departure, index) => ( {stopDepartures.map((departure, index) => (
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2"> <li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
<Chip>{departure.lineName}</Chip> <Chip>{departure.lineName}</Chip>
<span className="text-sm text-gray-600 dark:text-gray-300">{departure.direction}</span> <span className="text-sm text-[#F4F1EA]/70">{departure.direction}</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-semibold text-white">
{departure.minutes} min {departure.minutes} min
</span> </span>
</li> </li>
@@ -48,6 +48,46 @@ vi.mock("@/hooks/useBikeRoute", () => ({
}), }),
})); }));
vi.mock("@/hooks/useWalkRoute", () => ({
useWalkRoute: () => ({
walkRoute: null,
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useDepartureTime", () => ({
useDepartureTime: () => ({
departureTime: null,
arrivalTime: null,
mode: null,
}),
}));
vi.mock("@/hooks/useReminderSettings", () => ({
useReminderSettings: () => ({
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
bufferMinutes: 15,
enabled: true,
setArrivalBufferMinutes: () => {},
setShowWalkingOption: () => {},
setShowBikeOption: () => {},
setBufferMinutes: () => {},
setEnabled: () => {},
}),
}));
vi.mock("@/hooks/useWienerLinien", () => ({
useWienerLinien: () => ({
stops: [],
departures: [],
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useClock", () => ({ vi.mock("@/hooks/useClock", () => ({
useClock: () => ({ useClock: () => ({
countdown: { label: "No deadline set", color: "text-gray-400", urgent: false }, countdown: { label: "No deadline set", color: "text-gray-400", urgent: false },
@@ -0,0 +1,116 @@
// Test to verify JourneyList uses arrivalBufferMinutes correctly
import "@testing-library/jest-dom";
import React from "react";
import { render, screen } from "@testing-library/react";
import JourneyList from "@/app/event/JourneyList";
import { Journey } from "@timetoleave/core";
describe("JourneyList component", () => {
it("should render journey countdowns based on journey departure times", () => {
const mockJourneys: Journey[] = [
{
id: "1",
sD: new Date("2025-01-01T14:00:00Z"),
rD: new Date("2025-01-01T14:00:00Z"),
sA: new Date("2025-01-01T15:00:00Z"),
rA: new Date("2025-01-01T15:00:00Z"),
delay: 0,
platform: "1",
changes: 0,
trains: ["R1"],
cancelled: false,
},
];
const eventTime = new Date("2025-01-01T16:00:00Z");
render(
<JourneyList
journeys={mockJourneys}
eventTime={eventTime}
arrivalBufferMinutes={5}
/>
);
// Should show countdown to journey departure (14:00), not to event time (16:00)
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("should accept arrivalBufferMinutes prop", () => {
const mockJourneys: Journey[] = [
{
id: "1",
sD: new Date("2025-01-01T14:00:00Z"),
rD: new Date("2025-01-01T14:00:00Z"),
sA: new Date("2025-01-01T15:00:00Z"),
rA: new Date("2025-01-01T15:00:00Z"),
delay: 0,
platform: "1",
changes: 0,
trains: ["R1"],
cancelled: false,
},
];
const eventTime = new Date("2025-01-01T16:00:00Z");
// This should not throw an error
render(
<JourneyList
journeys={mockJourneys}
eventTime={eventTime}
arrivalBufferMinutes={10}
/>
);
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("should dim late journeys based on arrivalBufferMinutes", () => {
const lateJourney: Journey = {
id: "1",
sD: new Date("2025-01-01T14:00:00Z"),
rD: new Date("2025-01-01T14:00:00Z"),
sA: new Date("2025-01-01T16:00:00Z"), // Arrives after event time (too late)
rA: new Date("2025-01-01T16:00:00Z"),
delay: 0,
platform: "1",
changes: 0,
trains: ["R1"],
cancelled: false,
};
const onTimeJourney: Journey = {
id: "2",
sD: new Date("2025-01-01T14:00:00Z"),
rD: new Date("2025-01-01T14:00:00Z"),
sA: new Date("2025-01-01T15:00:00Z"), // Arrives before event time (on time)
rA: new Date("2025-01-01T15:00:00Z"),
delay: 0,
platform: "2",
changes: 0,
trains: ["R2"],
cancelled: false,
};
const eventTime = new Date("2025-01-01T15:30:00Z");
render(
<JourneyList
journeys={[lateJourney, onTimeJourney]}
eventTime={eventTime}
arrivalBufferMinutes={15} // 15 minutes buffer
/>
);
// Late journey should be dimmed and have line-through
const lateJourneyElement = screen.getByText("R1").closest("li");
expect(lateJourneyElement).toHaveClass("opacity-40");
expect(lateJourneyElement).toHaveClass("line-through");
// On-time journey should not be dimmed
const onTimeJourneyElement = screen.getByText("R2").closest("li");
expect(onTimeJourneyElement).not.toHaveClass("opacity-40");
expect(onTimeJourneyElement).not.toHaveClass("line-through");
});
});
@@ -0,0 +1,231 @@
// Test to verify TrainSection handles new logic (WalkingOption conditional, prop forwarding)
import "@testing-library/jest-dom";
import { vi, describe, it, expect } from "vitest";
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import TrainSection from "@/app/event/TrainSection";
import { Journey } from "@timetoleave/core";
// Mock WalkingOption component
vi.mock("../WalkingOption", () => {
const MockWalkingOption = vi.fn(() => <div data-testid="walking-option">Walking Option</div>);
return { default: MockWalkingOption };
});
describe("TrainSection component", () => {
const mockJourneys: Journey[] = [
{
id: "1",
sD: new Date("2025-01-01T14:00:00Z"),
rD: new Date("2025-01-01T14:00:00Z"),
sA: new Date("2025-01-01T15:00:00Z"),
rA: new Date("2025-01-01T15:00:00Z"),
delay: 0,
platform: "1",
changes: 0,
trains: ["R1"],
cancelled: false,
},
];
const eventTime = new Date("2025-01-01T16:00:00Z");
it("should render trains heading section with destination name and event time", () => {
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Vienna Station"
loading={false}
error={null}
/>
);
expect(screen.getByText("Trains")).toBeInTheDocument();
expect(screen.getByText(/To Vienna Station/)).toBeInTheDocument();
});
it("should render refresh button when onRefresh prop is provided", () => {
const mockRefresh = vi.fn();
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
onRefresh={mockRefresh}
/>
);
const refreshButton = screen.getByText("Refresh");
expect(refreshButton).toBeInTheDocument();
fireEvent.click(refreshButton);
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
it("should render loading state when loading is true", () => {
render(
<TrainSection
journeys={[]}
eventTime={eventTime}
destName="Test Station"
loading={true}
error={null}
/>
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
it("should render error message when error is present", () => {
const errorMessage = "Failed to load journeys";
render(
<TrainSection
journeys={[]}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={errorMessage}
/>
);
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it("should render journeys when not loading and no error", () => {
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
/>
);
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("should pass arrivalBufferMinutes prop to JourneyList", () => {
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
arrivalBufferMinutes={10}
/>
);
// JourneyList should receive the arrivalBufferMinutes prop
expect(screen.getByText("R1")).toBeInTheDocument();
});
it("should conditionally render WalkingOption when showWalkingOption is true and walkRoute is provided", () => {
const mockWalkRoute = {
distance: 1000,
duration: 300,
steps: [],
};
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
showWalkingOption={true}
walkRoute={mockWalkRoute}
walkLoading={false}
walkError={null}
/>
);
expect(screen.getByTestId("walking-option")).toBeInTheDocument();
});
it("should not render WalkingOption when showWalkingOption is false", () => {
const mockWalkRoute = {
distance: 1000,
duration: 300,
steps: [],
};
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
showWalkingOption={false}
walkRoute={mockWalkRoute}
walkLoading={false}
walkError={null}
/>
);
expect(screen.queryByTestId("walking-option")).not.toBeInTheDocument();
});
it("should pass walkLoading and walkError props to WalkingOption when showWalkingOption is true", () => {
const mockWalkRoute = {
distance: 1000,
duration: 300,
steps: [],
};
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
showWalkingOption={true}
walkRoute={mockWalkRoute}
walkLoading={true}
walkError="Walk error"
/>
);
expect(screen.getByTestId("walking-option")).toBeInTheDocument();
});
it("should apply custom className to container", () => {
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
className="custom-class"
/>
);
const container = screen.getByText("Trains").closest(".custom-class");
expect(container).toBeInTheDocument();
});
it("should use default className when none is provided", () => {
render(
<TrainSection
journeys={mockJourneys}
eventTime={eventTime}
destName="Test Station"
loading={false}
error={null}
/>
);
const container = screen.getByText("Trains").closest("div");
expect(container).toBeInTheDocument();
});
});
+81 -7
View File
@@ -1,26 +1,100 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #090816;
--foreground: #171717; --foreground: #1a1a2e;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif; --font-sans: var(--font-inter), "Poppins", "Montserrat", "Avenir Next", Arial, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
/* Brand palette derived from the TimeToLeave logo */
--color-brand-violet: #8B5CF6;
--color-brand-purple: #B23CFF;
--color-brand-fuchsia: #D946EF;
--color-brand-pink: #FF2D8D;
--color-brand-dark: #090816;
--color-brand-dark-surface: #17112A;
--color-brand-light: #F4F1EA;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: #090816;
--foreground: #ededed; --foreground: #F4F1EA;
} }
} }
body { body {
background: var(--background); min-height: 100vh;
background:
radial-gradient(circle at 50% 0%, rgba(178, 60, 255, 0.28), transparent 32rem),
radial-gradient(circle at 84% 18%, rgba(255, 45, 141, 0.16), transparent 24rem),
linear-gradient(180deg, #090816 0%, #100d1e 48%, #080712 100%);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans);
}
/* Gradient text utility */
.brand-gradient-text {
background: linear-gradient(135deg, #8B5CF6 0%, #B23CFF 40%, #D946EF 72%, #FF2D8D 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-shell {
position: relative;
isolation: isolate;
}
.brand-shell::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background-image:
linear-gradient(rgba(244, 241, 234, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(244, 241, 234, 0.035) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: linear-gradient(to bottom, black 0%, transparent 76%);
}
.brand-panel {
border: 1px solid rgba(244, 241, 234, 0.12);
background:
linear-gradient(145deg, rgba(23, 17, 42, 0.92), rgba(11, 10, 24, 0.9)),
rgba(23, 17, 42, 0.88);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(18px);
}
.brand-panel-soft {
border: 1px solid rgba(178, 60, 255, 0.18);
background: rgba(244, 241, 234, 0.045);
}
.brand-input {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgba(244, 241, 234, 0.14);
background: rgba(255, 255, 255, 0.06);
color: #F4F1EA;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.brand-input::placeholder {
color: rgba(244, 241, 234, 0.42);
}
.brand-input:focus {
outline: none;
border-color: rgba(217, 70, 239, 0.74);
box-shadow: 0 0 0 3px rgba(217, 70, 239, 0.16);
background: rgba(255, 255, 255, 0.08);
} }
+56
View File
@@ -0,0 +1,56 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">TimeToLeave app icon</title>
<desc id="desc">An icon-only TimeToLeave mark: a violet and magenta melting clock on a dark rounded-square background, flowing into a right arrow.</desc>
<defs>
<radialGradient id="iconBg" cx="50%" cy="36%" r="76%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="iconMelt" x1="205" y1="170" x2="840" y2="745" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="40%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="iconGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="9" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
.iconStroke { stroke: url(#iconMelt); stroke-width: 56; stroke-linecap: round; stroke-linejoin: round; }
.iconTick { stroke: #F4F1EA; stroke-width: 16; stroke-linecap: round; opacity: 0.96; }
.iconHand { stroke: #F4F1EA; stroke-width: 22; stroke-linecap: round; }
</style>
</defs>
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
<g filter="url(#iconGlow)">
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
<path class="iconStroke" d="M821 668 C855 643 890 626 928 615"/>
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
</g>
<g>
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>
<line class="iconTick" x1="704" y1="514" x2="737" y2="514"/>
<line class="iconTick" x1="512" y1="649" x2="512" y2="680"/>
<line class="iconTick" x1="388" y1="640" x2="411" y2="617"/>
<line class="iconTick" x1="318" y1="514" x2="351" y2="514"/>
<line class="iconTick" x1="388" y1="388" x2="411" y2="411"/>
</g>
<g>
<line class="iconHand" x1="512" y1="514" x2="512" y2="394"/>
<line class="iconHand" x1="512" y1="514" x2="631" y2="590"/>
<circle cx="512" cy="514" r="29" fill="#F4F1EA"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+17 -4
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { EventsProvider } from "@/hooks/useEventsStore"; import { EventsProvider } from "@/hooks/useEventsStore";
import { ReminderSettingsProvider } from "@/hooks/useReminderSettings"; import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
@@ -6,9 +7,21 @@ import Header from "@/app/layout/Header";
import Navbar from "@/app/layout/Navbar"; import Navbar from "@/app/layout/Navbar";
import ReminderEngine from "@/app/layout/ReminderEngine"; import ReminderEngine from "@/app/layout/ReminderEngine";
const inter = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-inter",
weight: ["400", "500", "600", "700", "800"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "TimeToLeave", title: "TimeToLeave — Smart Departure Planner",
description: "Plan your train journeys and compare with bicycle routing", description:
"Plan your train journeys, sync with your calendar, and know exactly when to leave for your appointments.",
icons: {
icon: "/icon.svg",
apple: "/icon.svg",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -17,8 +30,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" className="h-full antialiased"> <html lang="en" className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900"> <body className="brand-shell min-h-full flex flex-col bg-[#090816] text-brand-light">
<ReminderSettingsProvider> <ReminderSettingsProvider>
<EventsProvider> <EventsProvider>
<ReminderEngine /> <ReminderEngine />
+45 -21
View File
@@ -7,6 +7,8 @@ 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";
import Link from "next/link";
import { LogoHorizontal } from "@/app/ui/logos";
type HeaderProps = { type HeaderProps = {
className?: string; className?: string;
@@ -17,61 +19,83 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
const [showSettingsModal, setShowSettingsModal] = 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, mounted } = useTheme();
return ( return (
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}> <header className={`sticky top-0 z-40 border-b border-white/10 bg-[#090816]/82 shadow-[0_18px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl ${className}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center"> <div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white"> <Link
<span className="text-blue-600">ÖBB</span> Planner href="/"
</h1> className="flex h-12 items-center rounded-lg text-brand-light drop-shadow-[0_0_18px_rgba(178,60,255,0.28)] focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/70"
aria-label="TimeToLeave home"
>
<LogoHorizontal height={42} className="block h-[42px] w-auto" />
</Link>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center gap-2 sm:gap-3">
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-gray-300"> <div className="hidden md:flex items-center rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-brand-light/76">
<span> <span className="font-semibold text-white">
<strong>{events.length}</strong> {events.length}
</span> </span>
<span className="ml-1">events</span> <span className="ml-1">events</span>
{status === true ? ( {status === true ? (
<span className="ml-2 text-green-600">Online</span> <span className="ml-3 inline-flex items-center gap-1 text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Online
</span>
) : status === false ? ( ) : status === false ? (
<span className="ml-2 text-red-600">Offline</span> <span className="ml-3 inline-flex items-center gap-1 text-brand-pink">
<span className="h-1.5 w-1.5 rounded-full bg-brand-pink" />
Offline
</span>
) : null} ) : null}
</div> </div>
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}> <Button variant="primary" size="sm" onClick={() => setShowAddEventModal(true)}>
Add Event Add Event
</Button> </Button>
<button <button
onClick={toggle} onClick={toggle}
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-brand-light/80 transition-colors hover:border-brand-fuchsia/45 hover:bg-brand-fuchsia/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/60"
> >
{dark ? "☀️" : "🌙"} {mounted && dark ? (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
) : (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3 6.5 6.5 0 0 0 21 12.8z" />
</svg>
)}
</button> </button>
<button <button
onClick={() => setShowSettingsModal(true)} onClick={() => setShowSettingsModal(true)}
aria-label="Settings" aria-label="Settings"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-brand-light/80 transition-colors hover:border-brand-fuchsia/45 hover:bg-brand-fuchsia/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-brand-fuchsia/60"
> >
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5z" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.05A1.7 1.7 0 0 0 19.4 15z" />
</svg>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} /> <AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
{showSettingsModal && ( {showSettingsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-sm relative"> <div className="brand-panel relative w-full max-w-sm rounded-2xl p-6">
<button <button
onClick={() => setShowSettingsModal(false)} onClick={() => setShowSettingsModal(false)}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg" className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-lg text-brand-light/50 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Close settings" aria-label="Close settings"
> >
x
</button> </button>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Settings</h2> <h2 className="mb-4 text-lg font-semibold text-brand-light">Settings</h2>
<ReminderSettingsPanel /> <ReminderSettingsPanel />
</div> </div>
</div> </div>
+7 -3
View File
@@ -17,15 +17,19 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
]; ];
return ( return (
<nav className={`bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 ${className}`}> <nav className={`sticky bottom-0 z-30 border-t border-white/10 bg-[#090816]/88 backdrop-blur-xl ${className}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex space-x-8"> <div className="flex w-full gap-2 sm:w-auto">
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
className={`text-sm font-medium ${pathname === link.href ? "text-blue-600 border-b-2 border-blue-600" : "text-gray-500 hover:text-gray-700 hover:border-gray-300"}`} className={`flex-1 rounded-full px-4 py-2 text-center text-sm font-semibold transition-colors sm:flex-none ${
pathname === link.href
? "bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white shadow-[0_10px_28px_rgba(178,60,255,0.28)]"
: "text-[#F4F1EA]/58 hover:bg-white/[0.06] hover:text-white"
}`}
> >
{link.name} {link.name}
</Link> </Link>
+76
View File
@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
<defs>
<radialGradient id="ogBg" cx="50%" cy="40%" r="80%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="ogMelt" x1="100" y1="50" x2="700" y2="550" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="40%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="ogGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#ogBg)"/>
<!-- Melting clock icon -->
<g transform="translate(180, 130) scale(0.9)" filter="url(#ogGlow)">
<path d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
<path d="M460,465 C480,450 500,435 520,425"
fill="none" stroke="url(#ogMelt)" stroke-width="24" stroke-linecap="round"/>
<path d="M500,400 L540,420 L490,455 L500,430 Z" fill="#FF2D8D"/>
</g>
<!-- Clock ticks -->
<g stroke="#F4F1EA" stroke-width="8" stroke-linecap="round" opacity="0.96" transform="translate(180, 130) scale(0.9)">
<line x1="290" y1="180" x2="290" y2="200"/>
<line x1="370" y1="220" x2="385" y2="210"/>
<line x1="400" y1="310" x2="420" y2="310"/>
<line x1="290" y1="400" x2="290" y2="420"/>
<line x1="200" y1="390" x2="215" y2="375"/>
<line x1="170" y1="310" x2="190" y2="310"/>
<line x1="200" y1="230" x2="215" y2="245"/>
</g>
<!-- Clock hands -->
<g stroke="#F4F1EA" stroke-width="12" stroke-linecap="round" transform="translate(180, 130) scale(0.9)">
<line x1="290" y1="310" x2="290" y2="230"/>
<line x1="290" y1="310" x2="360" y2="355"/>
</g>
<circle cx="442" cy="413" r="14" fill="#F4F1EA" transform="scale(0.9) translate(53, 45)"/>
<!-- Wordmark -->
<text x="520" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
Time
</text>
<text x="740" y="290" fill="#FF2D8D" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
To
</text>
<text x="860" y="290" fill="#F4F1EA" font-size="80" font-weight="800" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" letter-spacing="-2">
Leave
</text>
<!-- Subtitle -->
<text x="520" y="370" fill="rgba(244,241,234,0.7)" font-size="32" font-weight="400" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
Smart Departure Planner
</text>
<!-- Tagline -->
<text x="520" y="430" fill="rgba(244,241,234,0.4)" font-size="22" font-weight="300" font-family="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif">
Never miss your train again. ⏱️ 🚆 🚲
</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+30 -5
View File
@@ -13,14 +13,39 @@ export default function Home() {
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()); .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
return ( return (
<main className="max-w-3xl mx-auto w-full px-4 py-6"> <main className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<section className="mb-7 overflow-hidden rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-7">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Departure desk</p>
<h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl">
Know when to leave before the clock turns hostile.
</h1>
</div>
<div className="grid grid-cols-2 gap-3 sm:min-w-60">
<div className="brand-panel-soft rounded-xl p-3">
<p className="text-2xl font-bold text-white">{upcoming.length}</p>
<p className="text-xs text-brand-light/60">upcoming</p>
</div>
<div className="brand-panel-soft rounded-xl p-3">
<p className="text-2xl font-bold text-white">{events.length}</p>
<p className="text-xs text-brand-light/60">total events</p>
</div>
</div>
</div>
</section>
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<div className="text-center py-16 text-gray-500 dark:text-gray-400"> <div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center">
<p className="text-lg font-medium">No upcoming events</p> <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-brand-pink text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]">
<p className="text-sm mt-1">Use &quot;Add Event&quot; to add one, or import from the Calendar.</p> T
</div>
<p className="mb-2 text-2xl font-bold text-white">No upcoming events</p>
<p className="mx-auto max-w-sm text-sm leading-6 text-brand-light/66">
Add an event or import your calendar to turn this into a live departure board.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-5">
{upcoming.map((event) => ( {upcoming.map((event) => (
<EventCard key={event.id} event={event} originStation={originStation} /> <EventCard key={event.id} event={event} originStation={originStation} />
))} ))}
+58
View File
@@ -0,0 +1,58 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">TimeToLeave app icon</title>
<desc id="desc">An icon-only TimeToLeave mark: a violet and magenta melting clock on a dark rounded-square background, flowing into a right arrow.</desc>
<defs>
<radialGradient id="iconBg" cx="50%" cy="36%" r="76%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="55%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="iconMelt" x1="205" y1="170" x2="840" y2="745" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="40%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="iconGlow" x="-25%" y="-25%" width="150%" height="150%">
<feGaussianBlur stdDeviation="9" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
.iconStroke { stroke: url(#iconMelt); stroke-width: 56; stroke-linecap: round; stroke-linejoin: round; }
.iconTick { stroke: #F4F1EA; stroke-width: 16; stroke-linecap: round; opacity: 0.96; }
.iconHand { stroke: #F4F1EA; stroke-width: 22; stroke-linecap: round; }
</style>
</defs>
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
<g filter="url(#iconGlow)">
<!-- Simplified app-icon melting clock -->
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
<path class="iconStroke" d="M821 668 C855 643 890 626 928 615"/>
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
</g>
<!-- Minimal face details for small-size legibility -->
<g>
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>
<line class="iconTick" x1="704" y1="514" x2="737" y2="514"/>
<line class="iconTick" x1="512" y1="649" x2="512" y2="680"/>
<line class="iconTick" x1="388" y1="640" x2="411" y2="617"/>
<line class="iconTick" x1="318" y1="514" x2="351" y2="514"/>
<line class="iconTick" x1="388" y1="388" x2="411" y2="411"/>
</g>
<g>
<line class="iconHand" x1="512" y1="514" x2="512" y2="394"/>
<line class="iconHand" x1="512" y1="514" x2="631" y2="590"/>
<circle cx="512" cy="514" r="29" fill="#F4F1EA"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,69 @@
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">TimeToLeave dark melting clock logo</title>
<desc id="desc">A dark-background TimeToLeave logo with a violet and magenta melting clock flowing into an arrow, plus a wordmark.</desc>
<defs>
<radialGradient id="bgGlow" cx="50%" cy="38%" r="70%">
<stop offset="0%" stop-color="#17112A"/>
<stop offset="58%" stop-color="#090816"/>
<stop offset="100%" stop-color="#03030A"/>
</radialGradient>
<linearGradient id="meltGradient" x1="280" y1="210" x2="900" y2="720" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="42%" stop-color="#B23CFF"/>
<stop offset="72%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#FF2D8D"/>
</linearGradient>
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
.clockStroke { stroke: url(#meltGradient); stroke-width: 42; stroke-linecap: round; stroke-linejoin: round; }
.faceMark { stroke: #F4F1EA; stroke-width: 15; stroke-linecap: round; opacity: 0.96; }
.hand { stroke: #F4F1EA; stroke-width: 19; stroke-linecap: round; }
.word { font-family: Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif; font-weight: 700; font-size: 116px; letter-spacing: -4px; }
</style>
</defs>
<rect width="1200" height="1200" fill="url(#bgGlow)"/>
<g filter="url(#softGlow)">
<!-- Melting clock outline and flowing departure arrow -->
<path class="clockStroke" d="M334 552 C324 498 335 437 370 388 C415 324 486 290 562 293 C660 297 744 372 758 471 C763 508 758 541 753 565 C748 589 756 610 779 622 C808 637 810 594 832 590 C855 586 860 620 851 644 C842 669 824 682 803 678"/>
<path class="clockStroke" d="M334 552 C322 588 331 622 365 628 C395 633 386 581 415 580 C447 579 441 641 475 641 C510 641 506 585 544 585 C579 585 571 651 604 670 C650 697 733 695 797 657"/>
<path class="clockStroke" d="M475 641 C472 690 474 724 489 724 C506 724 506 690 509 651"/>
<path class="clockStroke" d="M797 657 C830 639 862 624 898 615"/>
<path d="M882 572 L1010 620 L904 708 L917 650 Z" fill="#FF2D8D"/>
<ellipse cx="505" cy="725" rx="17" ry="26" fill="#B23CFF"/>
</g>
<!-- Clock ticks -->
<g>
<line class="faceMark" x1="556" y1="334" x2="556" y2="360"/>
<line class="faceMark" x1="694" y1="391" x2="716" y2="378"/>
<line class="faceMark" x1="733" y1="526" x2="762" y2="526"/>
<line class="faceMark" x1="556" y1="670" x2="556" y2="698"/>
<line class="faceMark" x1="405" y1="655" x2="425" y2="633"/>
<line class="faceMark" x1="362" y1="526" x2="390" y2="526"/>
<line class="faceMark" x1="405" y1="398" x2="425" y2="420"/>
<line class="faceMark" x1="658" y1="420" x2="671" y2="398"/>
</g>
<!-- Clock hands -->
<g>
<line class="hand" x1="556" y1="526" x2="556" y2="405"/>
<line class="hand" x1="556" y1="526" x2="665" y2="590"/>
<circle cx="556" cy="526" r="25" fill="#F4F1EA"/>
</g>
<!-- Wordmark -->
<g aria-label="TimeToLeave">
<text class="word" x="163" y="912" fill="#F4F1EA">Time</text>
<text class="word" x="502" y="912" fill="#FF2D8D">To</text>
<text class="word" x="654" y="912" fill="#F4F1EA">Leave</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+8 -4
View File
@@ -3,16 +3,20 @@
import React from "react"; import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger"; variant?: "primary" | "secondary" | "accent" | "danger";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
}; };
const Button: React.FC<ButtonProps> = ({ children, className = "", variant = "primary", size = "md", ...props }) => { const Button: React.FC<ButtonProps> = ({ children, className = "", variant = "primary", size = "md", ...props }) => {
const baseStyles = "font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2"; const baseStyles =
"inline-flex items-center justify-center font-semibold rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-[#D946EF]/70 disabled:cursor-not-allowed disabled:opacity-55";
const variantStyles = { const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", primary:
"bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white shadow-[0_14px_34px_rgba(178,60,255,0.34)] hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(255,45,141,0.28)]",
secondary: secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-500", "border border-white/10 bg-white/[0.07] text-[#F4F1EA] hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12",
accent:
"bg-[#FF2D8D] text-white shadow-[0_12px_30px_rgba(255,45,141,0.3)] hover:bg-[#E5247D]",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
}; };
const sizeStyles = { const sizeStyles = {
+1 -1
View File
@@ -10,7 +10,7 @@ type ChipProps = {
const Chip: React.FC<ChipProps> = ({ children, className = '' }) => { const Chip: React.FC<ChipProps> = ({ children, className = '' }) => {
return ( return (
<span className={ <span className={
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 ${className}` `inline-flex items-center rounded-full border border-[#D946EF]/24 bg-[#B23CFF]/16 px-2.5 py-1 text-xs font-semibold text-[#F4F1EA] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ${className}`
}> }>
{children} {children}
</span> </span>
+5 -5
View File
@@ -4,11 +4,11 @@ import React from "react";
import Chip from "./Chip"; import Chip from "./Chip";
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
red: "text-red-600 bg-red-50 dark:bg-red-900/30", red: "border-red-400/30 bg-red-500/16 text-red-100",
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30", orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30", yellow: "border-yellow-300/30 bg-yellow-300/16 text-yellow-100",
green: "text-green-600 bg-green-50 dark:bg-green-900/30", green: "border-emerald-300/30 bg-emerald-400/16 text-emerald-100",
blue: "text-blue-600 bg-blue-50 dark:bg-blue-900/30", blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
}; };
type CountdownBadgeProps = { type CountdownBadgeProps = {
+6 -3
View File
@@ -18,9 +18,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}; };
return ( return (
<div className={ <div
`inline-block animate-spin rounded-full border-4 border-solid border-blue-500 border-t-transparent ${sizeClasses[size]} ${className}` className={
}> `inline-block animate-spin rounded-full border-4 border-solid border-brand-purple border-t-transparent ${sizeClasses[size]} ${className}`
}
data-testid="loading-spinner"
>
<span className="sr-only">Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
); );
+107
View File
@@ -0,0 +1,107 @@
export default function LogoHorizontal({
height = 44,
className = "",
}: {
height?: number;
className?: string;
}) {
const width = height * 4.85;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 214 58"
width={width}
height={height}
className={className}
aria-label="TimeToLeave"
role="img"
>
<defs>
<linearGradient id="ttl-header-gradient" x1="280" y1="210" x2="900" y2="720" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#8B5CF6" />
<stop offset="42%" stopColor="#B23CFF" />
<stop offset="72%" stopColor="#D946EF" />
<stop offset="100%" stopColor="#FF2D8D" />
</linearGradient>
<linearGradient id="ttl-header-text-gradient" x1="116" y1="17" x2="155" y2="43" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#B23CFF" />
<stop offset="100%" stopColor="#FF2D8D" />
</linearGradient>
<filter id="ttl-header-glow" x="-35%" y="-35%" width="170%" height="170%">
<feGaussianBlur stdDeviation="1.6" result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.32 0"
result="glow"
/>
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter="url(#ttl-header-glow)" transform="matrix(0.108 0 0 0.108 -31.6 -27.7)">
<path
d="M334 552 C324 498 335 437 370 388 C415 324 486 290 562 293 C660 297 744 372 758 471 C763 508 758 541 753 565 C748 589 756 610 779 622 C808 637 810 594 832 590 C855 586 860 620 851 644 C842 669 824 682 803 678"
fill="none"
stroke="url(#ttl-header-gradient)"
strokeWidth="42"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M334 552 C322 588 331 622 365 628 C395 633 386 581 415 580 C447 579 441 641 475 641 C510 641 506 585 544 585 C579 585 571 651 604 670 C650 697 733 695 797 657"
fill="none"
stroke="url(#ttl-header-gradient)"
strokeWidth="42"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M475 641 C472 690 474 724 489 724 C506 724 506 690 509 651"
fill="none"
stroke="url(#ttl-header-gradient)"
strokeWidth="42"
strokeLinecap="round"
/>
<path
d="M797 657 C830 639 862 624 898 615"
fill="none"
stroke="url(#ttl-header-gradient)"
strokeWidth="42"
strokeLinecap="round"
/>
<path d="M882 572 L1010 620 L904 708 L917 650 Z" fill="#FF2D8D" />
<ellipse cx="505" cy="725" rx="17" ry="26" fill="#B23CFF" />
<g stroke="currentColor" strokeLinecap="round" opacity="0.96">
<line x1="556" y1="334" x2="556" y2="360" strokeWidth="15" />
<line x1="694" y1="391" x2="716" y2="378" strokeWidth="15" />
<line x1="733" y1="526" x2="762" y2="526" strokeWidth="15" />
<line x1="405" y1="655" x2="425" y2="633" strokeWidth="15" />
<line x1="362" y1="526" x2="390" y2="526" strokeWidth="15" />
<line x1="405" y1="398" x2="425" y2="420" strokeWidth="15" />
<line x1="658" y1="420" x2="671" y2="398" strokeWidth="15" />
<line x1="556" y1="526" x2="556" y2="405" strokeWidth="19" />
<line x1="556" y1="526" x2="665" y2="590" strokeWidth="19" />
</g>
<circle cx="556" cy="526" r="25" fill="currentColor" />
</g>
<g fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" fontSize="26" fontWeight="800">
<text x="83" y="37" fill="currentColor">
Time
</text>
<text x="139" y="37" fill="url(#ttl-header-text-gradient)">
To
</text>
<text x="166" y="37" fill="currentColor">
Leave
</text>
</g>
</svg>
);
}
+84
View File
@@ -0,0 +1,84 @@
export default function LogoIcon({
size = 48,
className = "",
}: {
size?: number;
className?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={size}
height={size}
className={className}
aria-label="TimeToLeave logo"
>
<defs>
<linearGradient
id="ttt-iconMelt"
x1="100"
y1="80"
x2="420"
y2="430"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#8B5CF6" />
<stop offset="40%" stopColor="#B23CFF" />
<stop offset="72%" stopColor="#D946EF" />
<stop offset="100%" stopColor="#FF2D8D" />
</linearGradient>
</defs>
{/* Melting clock outline */}
<g>
<path
d="M160,310 C150,280 160,240 180,210 C210,160 260,140 310,145 C370,150 420,210 430,280 C435,310 430,340 425,360 C420,380 430,400 450,410 C470,420 475,380 495,375 C510,370 520,410 505,440 C495,460 480,470 460,465"
fill="none"
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Melting drip */}
<path
d="M290,465 C288,490 290,510 300,510 C312,510 310,490 315,465"
fill="none"
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
/>
{/* Flowing arrow */}
<path
d="M460,465 C480,450 500,435 520,425"
fill="none"
stroke="url(#ttt-iconMelt)"
strokeWidth="28"
strokeLinecap="round"
/>
<path
d="M500,400 L540,420 L490,455 L500,430 Z"
fill="#FF2D8D"
/>
</g>
{/* Clock ticks */}
<g stroke="#F4F1EA" strokeWidth="10" strokeLinecap="round" opacity="0.96">
<line x1="290" y1="180" x2="290" y2="200" />
<line x1="370" y1="220" x2="385" y2="210" />
<line x1="400" y1="310" x2="420" y2="310" />
<line x1="290" y1="400" x2="290" y2="420" />
<line x1="200" y1="390" x2="215" y2="375" />
<line x1="170" y1="310" x2="190" y2="310" />
<line x1="200" y1="230" x2="215" y2="245" />
</g>
{/* Clock hands — 10:10 */}
<g stroke="#F4F1EA" strokeWidth="14" strokeLinecap="round">
<line x1="290" y1="310" x2="290" y2="230" />
<line x1="290" y1="310" x2="360" y2="355" />
</g>
<circle cx="290" cy="310" r="16" fill="#F4F1EA" />
</svg>
);
}
+87 -10
View File
@@ -8,7 +8,11 @@ type ReminderSettingsPanelProps = {
}; };
export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) { export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings(); const {
bufferMinutes, enabled, setBufferMinutes, setEnabled,
arrivalBufferMinutes, showWalkingOption, showBikeOption,
setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption,
} = useReminderSettings();
const permission = const permission =
typeof window !== "undefined" && "Notification" in window typeof window !== "undefined" && "Notification" in window
@@ -26,7 +30,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
<div className={`space-y-4 ${className}`}> <div className={`space-y-4 ${className}`}>
{/* Toggle */} {/* Toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200"> <span className="text-sm font-medium text-brand-light/80">
Leave reminders Leave reminders
</span> </span>
<button <button
@@ -34,7 +38,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
aria-checked={enabled} aria-checked={enabled}
onClick={() => setEnabled(!enabled)} onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ 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" enabled ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
}`} }`}
> >
<span <span
@@ -50,10 +54,10 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="buffer-minutes" htmlFor="buffer-minutes"
className="text-sm font-medium text-gray-700 dark:text-gray-200" className="text-sm font-medium text-brand-light/80"
> >
Remind me{" "} Remind me{" "}
<span className="text-gray-500 dark:text-gray-400"> <span className="text-brand-light/48">
(minutes before event) (minutes before event)
</span> </span>
</label> </label>
@@ -66,11 +70,11 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
step={1} step={1}
value={bufferMinutes} value={bufferMinutes}
onChange={(e) => setBufferMinutes(Number(e.target.value))} onChange={(e) => setBufferMinutes(Number(e.target.value))}
className="flex-1 accent-blue-600" className="flex-1 accent-brand-purple"
/> />
<output <output
htmlFor="buffer-minutes" htmlFor="buffer-minutes"
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-gray-200" className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-brand-light/80"
> >
{bufferMinutes} {bufferMinutes}
</output> </output>
@@ -78,13 +82,86 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
</div> </div>
)} )}
{/* Arrival buffer slider */}
<div className="space-y-2">
<label
htmlFor="arrival-buffer"
className="text-sm font-medium text-brand-light/80"
>
Arrive early
<span className="text-brand-light/48">
(minutes before event)
</span>
</label>
<div className="flex items-center gap-3">
<input
id="arrival-buffer"
type="range"
min={0}
max={30}
step={1}
value={arrivalBufferMinutes}
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
className="flex-1 accent-brand-purple"
/>
<output
htmlFor="arrival-buffer"
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-brand-light/80"
>
{arrivalBufferMinutes}
</output>
</div>
</div>
{/* Show walking option toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-brand-light/80">
Show walking option
</span>
<button
role="switch"
aria-checked={showWalkingOption}
onClick={() => setShowWalkingOption(!showWalkingOption)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showWalkingOption ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Show bike option toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-brand-light/80">
Show bike route
</span>
<button
role="switch"
aria-checked={showBikeOption}
onClick={() => setShowBikeOption(!showBikeOption)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-brand-pink" : "bg-white/16"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showBikeOption ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Permission status */} {/* Permission status */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700"> <div className="border-t border-white/10 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p> <p className="text-xs text-brand-light/50">{statusLabel}</p>
{permission !== "granted" && permission !== "denied" && ( {permission !== "granted" && permission !== "denied" && (
<button <button
onClick={() => Notification.requestPermission()} onClick={() => Notification.requestPermission()}
className="mt-2 text-xs text-blue-600 hover:underline" className="mt-2 text-xs text-brand-purple hover:underline"
> >
Request permission now Request permission now
</button> </button>
+2
View File
@@ -0,0 +1,2 @@
export { default as LogoIcon } from "./LogoIcon";
export { default as LogoHorizontal } from "./LogoHorizontal";
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { useDepartureTime } from "../useDepartureTime";
import type { Journey } from "@timetoleave/core";
import React from "react";
import { ReminderSettingsProvider } from "../useReminderSettings";
// Wrapper to provide the context
function wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(ReminderSettingsProvider, null, children);
}
describe("useDepartureTime", () => {
const eventTime = new Date("2024-01-01T12:00:00Z");
const eventTimeMs = eventTime.getTime();
// Helper to create mock journeys
const createJourney = (sD: string, sA: string, cancelled = false): Journey => ({
id: "test-journey",
sD: new Date(sD),
rD: new Date(sD),
sA: new Date(sA),
rA: new Date(sA),
delay: 0,
platform: "1",
changes: 0,
trains: ["IC123"],
cancelled,
});
it("should return null when no journeys are available", () => {
const { result } = renderHook(() =>
useDepartureTime(eventTime, null, null, "train")
, { wrapper });
expect(result.current.departureTime).toBeNull();
expect(result.current.arrivalTime).toBeNull();
expect(result.current.mode).toBeNull();
});
it("should calculate correct departure time for train mode", () => {
const journeys: Journey[] = [
createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z"), // arrives 15 min early
createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"), // arrives 5 min early
createJourney("2024-01-01T11:00:00Z", "2024-01-01T12:10:00Z"), // arrives 10 min late (should be ignored)
];
const { result } = renderHook(() =>
useDepartureTime(eventTime, journeys, null, "train")
, { wrapper });
expect(result.current.departureTime).not.toBeNull();
expect(result.current.arrivalTime).not.toBeNull();
expect(result.current.mode).toBe("train");
// Should pick the journey with the latest departure (10:45) that arrives on time (11:55)
if (result.current.departureTime) {
expect(result.current.departureTime.getTime()).toBe(
new Date("2024-01-01T10:45:00Z").getTime()
);
}
});
it("should filter out cancelled journeys", () => {
const journeys: Journey[] = [
createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z", true), // cancelled
createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"), // valid
];
const { result } = renderHook(() =>
useDepartureTime(eventTime, journeys, null, "train")
, { wrapper });
expect(result.current.departureTime).not.toBeNull();
expect(result.current.departureTime?.getTime()).toBe(
new Date("2024-01-01T10:45:00Z").getTime()
);
});
it("should calculate correct departure time for bike mode", () => {
const bikeRouteDuration = 1800; // 30 minutes in seconds
const { result } = renderHook(() =>
useDepartureTime(eventTime, null, bikeRouteDuration, "bike")
, { wrapper });
expect(result.current.departureTime).not.toBeNull();
expect(result.current.arrivalTime).not.toBeNull();
expect(result.current.mode).toBe("bike");
// Should account for bike duration + arrival buffer (default 5 min)
// Total travel time: 30 min bike route + 5 min buffer = 35 min
const expectedDepartureTime = new Date(eventTimeMs - 35 * 60 * 1000);
expect(result.current.departureTime?.getTime()).toBeCloseTo(
expectedDepartureTime.getTime()
);
});
it("should return null when bike route duration is invalid", () => {
const { result } = renderHook(() =>
useDepartureTime(eventTime, null, 0, "bike")
, { wrapper });
expect(result.current.departureTime).toBeNull();
expect(result.current.arrivalTime).toBeNull();
expect(result.current.mode).toBeNull();
});
});
@@ -13,12 +13,12 @@ function makeHafasSuccessData() {
ctxRecon: "journey-1", ctxRecon: "journey-1",
secL: [ secL: [
{ {
dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" }, dep: { dTimeS: "120000", dTimeR: "120000" },
prod: { name: "Railjet 123" }, jny: { stopL: [{ name: "RJX 123" }] },
}, },
{ {
arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" }, arr: { aTimeS: "130000", aTimeR: "130000" },
prod: { name: "Railjet 123" }, jny: { stopL: [{ name: "RJX 123" }] },
}, },
], ],
}, },
@@ -0,0 +1,67 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useWalkRoute } from '../useWalkRoute';
import { vi } from 'vitest';
describe('useWalkRoute integration', () => {
it('should handle missing coordinates gracefully', () => {
const { result } = renderHook(() =>
useWalkRoute(undefined, undefined, undefined, undefined)
);
expect(result.current.loading).toBe(false);
expect(result.current.walkRoute).toBe(null);
expect(result.current.error).toBe(null);
});
it('should handle API errors', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, {
status: 500,
statusText: 'Internal Server Error',
})
);
vi.stubGlobal('fetch', mockFetch);
const { result } = renderHook(() =>
useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748)
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeDefined();
expect(result.current.walkRoute).toBe(null);
});
it('should work with valid coordinates', async () => {
const mockRoute = {
distance: 500,
duration: 300,
steps: [
{
name: 'Start walking',
distance: 500,
duration: 300,
instruction: 'Walk straight ahead',
},
],
};
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(mockRoute), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', mockFetch);
const { result } = renderHook(() =>
useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748)
);
await waitFor(() => expect(result.current.loading).toBe(true));
expect(result.current.walkRoute).toBe(null);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.walkRoute).toEqual(mockRoute);
expect(result.current.error).toBe(null);
});
});
+6 -4
View File
@@ -8,7 +8,7 @@ interface ClockResult {
status: "upcoming" | "now" | "past"; status: "upcoming" | "now" | "past";
} }
export function useClock(targetDate: Date): ClockResult { export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
const [now, setNow] = useState<Date>(new Date()); const [now, setNow] = useState<Date>(new Date());
useEffect(() => { useEffect(() => {
@@ -19,9 +19,11 @@ export function useClock(targetDate: Date): ClockResult {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const effectiveTarget = departureTime ?? targetDate;
return useMemo(() => { return useMemo(() => {
const countdown = calculateCountdown(targetDate); const countdown = calculateCountdown(effectiveTarget);
const diffMs = targetDate.getTime() - now.getTime(); const diffMs = effectiveTarget.getTime() - now.getTime();
let status: "upcoming" | "now" | "past"; let status: "upcoming" | "now" | "past";
if (diffMs <= 0) { if (diffMs <= 0) {
@@ -33,5 +35,5 @@ export function useClock(targetDate: Date): ClockResult {
} }
return { countdown, status }; return { countdown, status };
}, [targetDate, now]); }, [effectiveTarget, now]);
} }
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useMemo } from "react";
import type { Journey } from "@timetoleave/core";
import { useReminderSettings } from "@/hooks/useReminderSettings";
interface DepartureTimeResult {
departureTime: Date | null;
arrivalTime: Date | null;
mode: "train" | "bike" | null;
}
export function useDepartureTime(
eventTime: Date,
journeys: Journey[] | null,
bikeRoute: number | null, // duration in seconds
activeMode: "train" | "bike" | null
): DepartureTimeResult {
const { arrivalBufferMinutes } = useReminderSettings();
return useMemo(() => {
// Calculate target arrival time (event time minus buffer)
const targetArrivalTime = new Date(eventTime);
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
// Filter out cancelled journeys
const validJourneys = journeys?.filter(journey => !journey.cancelled) || [];
let departureTime: Date | null = null;
let arrivalTime: Date | null = null;
let mode: "train" | "bike" | null = null;
if (activeMode === "train" && validJourneys.length > 0) {
const onTimeJourneys = validJourneys.filter(journey =>
journey.rA.getTime() <= targetArrivalTime.getTime()
);
if (onTimeJourneys.length > 0) {
const bestJourney = onTimeJourneys.reduce((latest, current) =>
current.rD.getTime() > latest.rD.getTime() ? current : latest
);
departureTime = new Date(bestJourney.rD);
arrivalTime = new Date(bestJourney.rA);
mode = "train";
}
}
if (activeMode === "bike" && bikeRoute !== null && bikeRoute > 0) {
const bikeDurationMs = bikeRoute * 1000;
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
const targetArrivalMs = eventTime.getTime() - totalBufferMs;
departureTime = new Date(targetArrivalMs - bikeDurationMs);
arrivalTime = new Date(targetArrivalMs);
mode = "bike";
}
return { departureTime, arrivalTime, mode };
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes]);
}
+29 -7
View File
@@ -8,6 +8,8 @@ interface HafasLocation {
type: string; type: string;
name: string; name: string;
extId: string; extId: string;
lat: number;
lon: number;
} }
export function useDestinationStation(destination: string) { export function useDestinationStation(destination: string) {
@@ -18,33 +20,54 @@ export function useDestinationStation(destination: string) {
useEffect(() => { useEffect(() => {
if (!destination.trim()) return; if (!destination.trim()) return;
let isMounted = true; let isMounted = true;
let abortController: AbortController | null = null;
const timeoutId = setTimeout(async () => { const timeoutId = setTimeout(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
abortController = new AbortController();
try { try {
const geocodeResults = await client.geocode(destination, "at");
const coords = geocodeResults[0];
if (!coords) {
if (isMounted) {
setStation(null);
setLoading(false);
}
return;
}
const body = { const body = {
svcReqL: [ svcReqL: [
{ {
meth: "LocMatch", meth: "LocMatch",
req: { input: { loc: { name: destination, type: "S" }, maxLoc: 1 } }, req: {
input: {
loc: {
crd: {
x: Math.round(coords.lng * 1e6),
y: Math.round(coords.lat * 1e6),
},
type: "S",
},
maxLoc: 1,
field: "S",
},
},
}, },
], ],
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await client.hafasRequest<any>(body); const data = await client.hafasRequest<any>(body);
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? []; const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
const stations = locL.filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId })); const stations = locL
.filter((l) => l.type === "S")
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
if (!isMounted) return; if (!isMounted) return;
setStation(stations[0] ?? null); setStation(stations[0] ?? null);
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (isMounted) { if (isMounted) {
setError(err instanceof Error ? err.message : "Station lookup failed"); setError(err instanceof Error ? err.message : "Station lookup failed");
setLoading(false); setLoading(false);
@@ -55,7 +78,6 @@ export function useDestinationStation(destination: string) {
return () => { return () => {
isMounted = false; isMounted = false;
clearTimeout(timeoutId); clearTimeout(timeoutId);
abortController?.abort();
}; };
}, [destination]); }, [destination]);
+5 -3
View File
@@ -34,7 +34,6 @@ export function useOriginStation() {
try { try {
const { latitude, longitude } = location.coords; const { latitude, longitude } = location.coords;
// Use HAFAS LocMatch with coordinates to find nearest station
const body = { const body = {
svcReqL: [ svcReqL: [
{ {
@@ -42,11 +41,14 @@ export function useOriginStation() {
req: { req: {
input: { input: {
loc: { loc: {
lat: latitude, crd: {
lon: longitude, x: Math.round(longitude * 1e6),
y: Math.round(latitude * 1e6),
},
type: "S", type: "S",
}, },
maxLoc: 5, maxLoc: 5,
field: "S",
}, },
}, },
}, },
+23 -2
View File
@@ -4,7 +4,13 @@ import { createContext, useContext, useState, useCallback, useEffect, ReactNode
import { ReminderSettings } from "@timetoleave/core"; import { ReminderSettings } from "@timetoleave/core";
const STORAGE_KEY = "ttl_reminder_settings"; const STORAGE_KEY = "ttl_reminder_settings";
const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true }; const DEFAULTS: ReminderSettings = {
bufferMinutes: 15,
enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
};
function loadFromStorage(): ReminderSettings { function loadFromStorage(): ReminderSettings {
if (typeof window === "undefined") return DEFAULTS; if (typeof window === "undefined") return DEFAULTS;
@@ -20,6 +26,9 @@ function loadFromStorage(): ReminderSettings {
interface ReminderContextType extends ReminderSettings { interface ReminderContextType extends ReminderSettings {
setBufferMinutes: (minutes: number) => void; setBufferMinutes: (minutes: number) => void;
setEnabled: (enabled: boolean) => void; setEnabled: (enabled: boolean) => void;
setArrivalBufferMinutes: (minutes: number) => void;
setShowWalkingOption: (show: boolean) => void;
setShowBikeOption: (show: boolean) => void;
} }
const ReminderContext = createContext<ReminderContextType | undefined>(undefined); const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
@@ -39,8 +48,20 @@ export function ReminderSettingsProvider({ children }: { children: ReactNode })
setSettings((prev) => ({ ...prev, enabled })); setSettings((prev) => ({ ...prev, enabled }));
}, []); }, []);
const setArrivalBufferMinutes = useCallback((minutes: number) => {
setSettings((prev) => ({ ...prev, arrivalBufferMinutes: Math.max(0, Math.min(30, minutes)) }));
}, []);
const setShowWalkingOption = useCallback((show: boolean) => {
setSettings((prev) => ({ ...prev, showWalkingOption: show }));
}, []);
const setShowBikeOption = useCallback((show: boolean) => {
setSettings((prev) => ({ ...prev, showBikeOption: show }));
}, []);
return ( return (
<ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}> <ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled, setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption }}>
{children} {children}
</ReminderContext.Provider> </ReminderContext.Provider>
); );
+8 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
const THEME_KEY = "ttl_theme"; const THEME_KEY = "ttl_theme";
@@ -17,9 +17,14 @@ function getClientTheme(): "dark" | "light" {
export function useTheme() { export function useTheme() {
const [dark, setDark] = useState(() => getClientTheme() === "dark"); const [dark, setDark] = useState(() => getClientTheme() === "dark");
const [mounted] = useState(() => typeof window !== "undefined");
const initialized = useRef(false);
useEffect(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark); if (!initialized.current) {
initialized.current = true;
document.documentElement.classList.toggle("dark", dark);
}
}, [dark]); }, [dark]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
@@ -30,5 +35,5 @@ export function useTheme() {
}); });
}, []); }, []);
return { dark, toggle }; return { dark, toggle, mounted };
} }
+52
View File
@@ -0,0 +1,52 @@
import { useState, useEffect } from "react";
import type { WalkRoute } from "@timetoleave/core";
import { ApiClient } from "@timetoleave/api-client";
const client = new ApiClient();
export function useWalkRoute(
fromLat: number | undefined,
fromLng: number | undefined,
toLat: number | undefined,
toLng: number | undefined,
) {
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchRoute = async () => {
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
return;
}
setLoading(true);
setError(null);
try {
const data = await client.getWalkRoute(fromLat, fromLng, toLat, toLng);
if (isMounted) {
setWalkRoute(data);
setLoading(false);
}
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to fetch walk route";
setError(message);
setLoading(false);
}
}
};
fetchRoute();
return () => {
isMounted = false;
};
}, [fromLat, fromLng, toLat, toLng]);
return { walkRoute, loading, error };
}
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { NextResponse } from "next/server";
import { validateCoordinate, rateLimitExceededResponse, applyRateLimitHeaders } from "../api-guards";
describe("validateCoordinate", () => {
it("returns a valid latitude", () => {
expect(validateCoordinate("47.5", -90, 90)).toBe(47.5);
expect(validateCoordinate("-45", -90, 90)).toBe(-45);
expect(validateCoordinate("0", -90, 90)).toBe(0);
expect(validateCoordinate("90", -90, 90)).toBe(90);
expect(validateCoordinate("-90", -90, 90)).toBe(-90);
});
it("returns a valid longitude", () => {
expect(validateCoordinate("14.5", -180, 180)).toBe(14.5);
expect(validateCoordinate("180", -180, 180)).toBe(180);
expect(validateCoordinate("-180", -180, 180)).toBe(-180);
});
it("rejects out-of-range values", () => {
expect(validateCoordinate("91", -90, 90)).toBeNull();
expect(validateCoordinate("-91", -90, 90)).toBeNull();
expect(validateCoordinate("181", -180, 180)).toBeNull();
});
it("rejects non-numeric input", () => {
expect(validateCoordinate("abc", -90, 90)).toBeNull();
expect(validateCoordinate("14.5°N", -90, 90)).toBeNull();
expect(validateCoordinate("", -90, 90)).toBeNull();
expect(validateCoordinate(null, -90, 90)).toBeNull();
});
});
describe("rateLimitExceededResponse", () => {
it("returns a 429 response with rate-limit headers", () => {
const response = rateLimitExceededResponse(0, 30, 45_000);
expect(response.status).toBe(429);
expect(response.headers.get("X-RateLimit-Limit")).toBe("30");
expect(response.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(response.headers.get("Retry-After")).toBe("45");
});
});
describe("applyRateLimitHeaders", () => {
it("sets rate-limit headers on a response", () => {
const response = NextResponse.json({});
applyRateLimitHeaders(response, 12, 30);
expect(response.headers.get("X-RateLimit-Limit")).toBe("30");
expect(response.headers.get("X-RateLimit-Remaining")).toBe("12");
});
});
+18 -18
View File
@@ -199,63 +199,63 @@ describe("hafasDateTime", () => {
it("roundtrips standard CET times", () => { it("roundtrips standard CET times", () => {
expect(roundtrip("20240115", "080000")).toEqual({ expect(roundtrip("20240115", "080000")).toEqual({
date: "20240115", date: "20240115",
time: "080000000", time: "080000",
}); });
expect(roundtrip("20240201", "000000")).toEqual({ expect(roundtrip("20240201", "000000")).toEqual({
date: "20240201", date: "20240201",
time: "000000000", time: "000000",
}); });
expect(roundtrip("20241231", "235900")).toEqual({ expect(roundtrip("20241231", "235900")).toEqual({
date: "20241231", date: "20241231",
time: "235900000", time: "235900",
}); });
}); });
it("roundtrips standard CEST times", () => { it("roundtrips standard CEST times", () => {
expect(roundtrip("20240715", "080000")).toEqual({ expect(roundtrip("20240715", "080000")).toEqual({
date: "20240715", date: "20240715",
time: "080000000", time: "080000",
}); });
expect(roundtrip("20240801", "000000")).toEqual({ expect(roundtrip("20240801", "000000")).toEqual({
date: "20240801", date: "20240801",
time: "000000000", time: "000000",
}); });
}); });
it("roundtrips spring DST transition (Mar 31, 2024)", () => { it("roundtrips spring DST transition (Mar 31, 2024)", () => {
expect(roundtrip("20240331", "010000")).toEqual({ expect(roundtrip("20240331", "010000")).toEqual({
date: "20240331", date: "20240331",
time: "010000000", time: "010000",
}); });
expect(roundtrip("20240331", "030000")).toEqual({ expect(roundtrip("20240331", "030000")).toEqual({
date: "20240331", date: "20240331",
time: "030000000", time: "030000",
}); });
expect(roundtrip("20240331", "120000")).toEqual({ expect(roundtrip("20240331", "120000")).toEqual({
date: "20240331", date: "20240331",
time: "120000000", time: "120000",
}); });
}); });
it("roundtrips fall DST transition (Oct 27, 2024)", () => { it("roundtrips fall DST transition (Oct 27, 2024)", () => {
expect(roundtrip("20241027", "000000")).toEqual({ expect(roundtrip("20241027", "000000")).toEqual({
date: "20241027", date: "20241027",
time: "000000000", time: "000000",
}); });
expect(roundtrip("20241027", "023000")).toEqual({ expect(roundtrip("20241027", "023000")).toEqual({
date: "20241027", date: "20241027",
time: "023000000", time: "023000",
}); });
expect(roundtrip("20241027", "120000")).toEqual({ expect(roundtrip("20241027", "120000")).toEqual({
date: "20241027", date: "20241027",
time: "120000000", time: "120000",
}); });
}); });
it("roundtrips leap year", () => { it("roundtrips leap year", () => {
expect(roundtrip("20240229", "120000")).toEqual({ expect(roundtrip("20240229", "120000")).toEqual({
date: "20240229", date: "20240229",
time: "120000000", time: "120000",
}); });
}); });
}); });
@@ -266,7 +266,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0)); const utc = new Date(Date.UTC(2024, 0, 15, 7, 0, 0));
const { date, time } = hafasDateTime(utc); const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115"); expect(date).toBe("20240115");
expect(time).toBe("080000000"); expect(time).toBe("080000");
}); });
it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => { it("converts a UTC instant to correct Vienna HAFAS strings (CEST)", () => {
@@ -274,7 +274,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0)); const utc = new Date(Date.UTC(2024, 6, 15, 6, 0, 0));
const { date, time } = hafasDateTime(utc); const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240715"); expect(date).toBe("20240715");
expect(time).toBe("080000000"); expect(time).toBe("080000");
}); });
it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => { it("handles midnight wraparound (UTC 23:00 → Vienna +1 day 00:00)", () => {
@@ -282,7 +282,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0)); const utc = new Date(Date.UTC(2024, 0, 14, 23, 0, 0));
const { date, time } = hafasDateTime(utc); const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240115"); expect(date).toBe("20240115");
expect(time).toBe("000000000"); expect(time).toBe("000000");
}); });
it('produces "00" for midnight, not "24" (k-clock safety)', () => { it('produces "00" for midnight, not "24" (k-clock safety)', () => {
@@ -291,7 +291,7 @@ describe("hafasDateTime", () => {
// Dec 31 23:00 UTC = Jan 1 00:00 Vienna (CET) // Dec 31 23:00 UTC = Jan 1 00:00 Vienna (CET)
const midnightUtc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0)); const midnightUtc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { time: midnightTime } = hafasDateTime(midnightUtc); const { time: midnightTime } = hafasDateTime(midnightUtc);
expect(midnightTime).toBe("000000000"); expect(midnightTime).toBe("000000");
}); });
it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => { it("handles midnight wraparound (Vienna midnight is still previous UTC day)", () => {
@@ -299,7 +299,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0)); const utc = new Date(Date.UTC(2023, 11, 31, 23, 0, 0));
const { date, time } = hafasDateTime(utc); const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240101"); expect(date).toBe("20240101");
expect(time).toBe("000000000"); expect(time).toBe("000000");
}); });
it("pads single-digit components correctly", () => { it("pads single-digit components correctly", () => {
@@ -307,7 +307,7 @@ describe("hafasDateTime", () => {
const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7)); const utc = new Date(Date.UTC(2024, 0, 2, 7, 3, 7));
const { date, time } = hafasDateTime(utc); const { date, time } = hafasDateTime(utc);
expect(date).toBe("20240102"); expect(date).toBe("20240102");
expect(time).toBe("080307000"); expect(time).toBe("080307");
}); });
}); });
}); });
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { RateLimiter } from "../rate-limiter";
describe("RateLimiter", () => {
let limiter: RateLimiter;
beforeEach(() => {
limiter = new RateLimiter({
maxRequests: 5,
windowMs: 200,
cleanupIntervalMs: 10_000,
});
});
afterEach(() => {
limiter.destroy();
});
it("allows requests within the limit", () => {
for (let i = 0; i < 5; i++) {
const result = limiter.check("test-ip");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4 - i);
}
});
it("blocks requests over the limit", () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
const result = limiter.check("test-ip");
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it("allows again after window expires", async () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
expect(limiter.check("test-ip").allowed).toBe(false);
await new Promise((r) => setTimeout(r, 250));
expect(limiter.check("test-ip").allowed).toBe(true);
});
it("tracks keys independently", () => {
for (let i = 0; i < 5; i++) {
limiter.check("ip-a");
}
expect(limiter.check("ip-a").allowed).toBe(false);
expect(limiter.check("ip-b").allowed).toBe(true);
});
it("resets when clear is called", () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
limiter.clear();
expect(limiter.check("test-ip").allowed).toBe(true);
});
it("exposes correct limit value", () => {
const result = limiter.check("any");
expect(result.limit).toBe(5);
});
});
+97
View File
@@ -0,0 +1,97 @@
// ============================================================================
// API Guard Helpers — Body size limits & stricter validation
// ============================================================================
import { NextResponse } from "next/server";
// Maximum request body for any API POST: 1 MB (calendar ICS blobs are
// typically < 500 KB, so 1 MB gives plenty of headroom).
export const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024;
/**
* Read a text body with a size guard. Returns null if the body exceeds the
* configured maximum.
*/
export async function readBodyWithLimit(
request: { body: ReadableStream<Uint8Array> | null },
maxBytes: number = MAX_REQUEST_BODY_BYTES,
): Promise<string | null> {
if (!request.body) return null;
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
return null; // too large
}
chunks.push(value);
}
// Coalesce
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder("utf-8").decode(combined);
}
/**
* Validate that a float parameter is a valid coordinate in the expected range.
* Returns null if the value is invalid, otherwise returns the parsed number.
*/
export function validateCoordinate(
value: string | null,
min: number,
max: number,
): number | null {
if (!value) return null;
// Strict numeric check - only accept pure numbers
if (!/^-?\d+\.?\d*$/.test(value)) {
return null;
}
const n = parseFloat(value);
if (isNaN(n) || n < min || n > max) return null;
return n;
}
/**
* Build a 429 Too Many Requests JSON response with RateLimit-* headers.
*/
export function rateLimitExceededResponse(
remaining: number,
limit: number,
resetAtMs: number,
): NextResponse {
const resetSeconds = Math.ceil(resetAtMs / 1000);
return NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"Retry-After": String(resetSeconds),
},
},
);
}
/**
* Attach RateLimit-* headers to a successful response.
*/
export function applyRateLimitHeaders(
response: NextResponse,
remaining: number,
limit: number,
): void {
response.headers.set("X-RateLimit-Limit", String(limit));
response.headers.set("X-RateLimit-Remaining", String(remaining));
}
+2 -2
View File
@@ -5,6 +5,6 @@ export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToL
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org"; export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2"; export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
export const DEFAULT_DAYS = 14; export const DEFAULT_DAYS = 14;
export const DEFAULT_STATION_NAME = "Wiener Hauptbahnhof"; export const DEFAULT_STATION_NAME = "Mödling Bahnhof";
export const DEFAULT_STATION_EXT_ID = "wik9000001"; export const DEFAULT_STATION_EXT_ID = "1231701";
export const APP_VERSION = process.env.APP_VERSION || "0.1.0"; export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
+1 -1
View File
@@ -118,7 +118,7 @@ export class HafasClient {
svcReqL: [ svcReqL: [
{ {
meth: "LocMatch", meth: "LocMatch",
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5 } }, req: { input: { loc: { name: query, type: "S" }, maxLoc: 5, field: "S" } },
}, },
], ],
}; };

Some files were not shown because too many files have changed in this diff Show More