Files
time_to_leave/FEATURES_PLAN.md
T
fegger 1851d2ed47 Add TimeToLeave feature checklist and plan (50)
Add TimeToLeave feature checklist and plan
2026-05-12 13:17:17 +02:00

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:

  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:

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/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:

{/* 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/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:

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/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:
type TransportMode = "train" | "bike";
const [activeMode, setActiveMode] = useState<TransportMode>("train");
  1. Use settings:
const { arrivalBufferMinutes, showWalkingOption, showBikeOption } = useReminderSettings();
  1. 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,
);
  1. Use departure time hook:
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute, activeMode);
  1. Pass departureTime to useClock:
const { countdown, status } = useClock(event.eventTime, departureTime);
  1. 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>
  1. 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:

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:

  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:

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