Add TimeToLeave feature checklist and plan
17 KiB
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:
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:
- Add the three new fields to the interface
- No changes needed to other types
Acceptance criteria:
packages/corecompiles without errorsnpm run typecheck -w packages/corepasses
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:
const DEFAULTS: ReminderSettings = {
bufferMinutes: 15,
enabled: true,
arrivalBufferMinutes: 5,
showWalkingOption: true,
showBikeOption: true,
};
Update context interface:
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:
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/webpasses
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:
{/* 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:
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/websucceeds
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:
- Copy
apps/web/src/lib/bike-routing-client.tstowalk-routing-client.ts - Rename class to
WalkRoutingClient - Change OSRM path from
/route/v1/bicycle/to/route/v1/foot/ - Change cache key prefix from
osrm:bike:toosrm:walk: - Rename
getBikeRoutetogetWalkRoute
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:
- Copy
apps/web/src/app/api/bike-route/route.tstowalk-route/route.ts - Import
WalkRoutingClientinstead ofBikeRoutingClient - 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
BikeRouteshape (reused type)
Step 6: Add getWalkRoute to ApiClient (~5 min)
File: packages/api-client/src/client.ts
Goal: Add getWalkRoute method mirroring getBikeRoute.
Actions:
- Copy
getBikeRoutemethod - Rename to
getWalkRoute - Change URL from
/api/bike-routeto/api/walk-route - 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-clientpasses
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:
- Create hook accepting
eventTime,journeys,bikeRoute,activeMode - Import
useReminderSettingsforarrivalBufferMinutes - Calculate
targetArrivalTime = eventTime - arrivalBufferMinutes - For train mode: find journeys arriving before target, pick latest departure
- For bike mode: calculate departure from target minus bike duration
- 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
useMemoto 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:
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
Update memoized logic:
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/webpasses
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:
- Copy
apps/web/src/hooks/useBikeRoute.tstouseWalkRoute.ts - Rename state variables from
bikeRoutetowalkRoute - Call
client.getWalkRouteinstead ofclient.getBikeRoute - 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:
- Create component accepting
walkRoute,walkLoading,walkErrorprops - Show loading spinner while fetching
- When route exists, show walk time in minutes and distance in km
- Use a pedestrian icon (inline SVG or emoji)
- 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:
- Add mode state:
type TransportMode = "train" | "bike";
const [activeMode, setActiveMode] = useState<TransportMode>("train");
- Use settings:
const { arrivalBufferMinutes, showWalkingOption, showBikeOption } = useReminderSettings();
- Fetch walk route from destination station to destination:
const { walkRoute, loading: walkLoading, error: walkError } = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
- Use departure time hook:
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute, activeMode);
- Pass departureTime to useClock:
const { countdown, status } = useClock(event.eventTime, departureTime);
- Add mode selector UI between the header info and the sections:
<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>
- 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
showBikeOptionis 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:
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:
<JourneyList
journeys={journeys}
eventTime={eventTime}
arrivalBufferMinutes={arrivalBufferMinutes ?? 0}
/>
Add WalkingOption at bottom when enabled:
{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:
type JourneyListProps = {
journeys: Journey[];
eventTime: Date;
arrivalBufferMinutes: number;
className?: string;
};
Calculate adjusted target:
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
Filter out journeys arriving after target:
const validJourneys = journeys.filter(j => !j.cancelled);
Dim journeys that arrive too late:
const arrivesTooLate = j.rA.getTime() > targetArrivalTime.getTime();
Apply dimmed styling to late journeys:
<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:
- Open settings, set arrival buffer to 10 minutes
- Verify countdown badge shows earlier departure time than event time
- Switch to bike mode, verify countdown updates to bike departure time
- Enable walking option, verify walk duration appears under train section
- Toggle bike option off, verify bike section disappears
- 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:
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:
- Check current exports in index.ts
- Add
getWalkRoutemethod to the exported class if not already present - Verify the method is accessible from web app imports
Acceptance criteria:
getWalkRouteis importable from@timetoleave/api-client- No breaking changes to existing exports