72f9400756
CI / lint-typecheck-test (push) Has been cancelled
Configure GitHub Actions for linting, typechecking, and testing. Add Husky and lint-staged for pre-commit checks. Implement API response caching in AsyncStorage for offline support. Move core tests to packages/core and add vitest configuration.
605 lines
17 KiB
Markdown
605 lines
17 KiB
Markdown
## 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
|
|
|
|
---
|