Add TimeToLeave feature checklist and plan (50)

Add TimeToLeave feature checklist and plan
This commit is contained in:
2026-05-12 13:17:17 +02:00
parent eac4f6e216
commit 1851d2ed47
45 changed files with 2234 additions and 259 deletions
+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
---