diff --git a/FEATURES_CHECKLIST.md b/FEATURES_CHECKLIST.md index 392e5f3..f4daa17 100644 --- a/FEATURES_CHECKLIST.md +++ b/FEATURES_CHECKLIST.md @@ -35,16 +35,16 @@ | # | Step | ✅ | ✔️ | |---|---|----|----| -| 12 | Update TrainSection props (arrival buffer + walk option) | [x] | [ ] | -| 13 | Update JourneyList with arrival buffer filtering | [x] | [ ] | +| 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) | [ ] | [ ] | -| 16 | Update api-client exports | [ ] | [ ] | +| 15 | Build verification (typecheck, lint, test, build) | [x] | [x] | +| 16 | Update api-client exports | [x] | [x] | --- diff --git a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json index fa5c94a..6a59bf5 100644 --- a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ b/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -1 +1 @@ -{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4073.7258190000002,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":95.46945000000005,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":49.78008,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":11.022554000000014,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2122.026867,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":65.458756,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":65.25084599999991,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":11.561225000000036,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":59.768212000000176,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":28.255799000000025,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":36.11284100000012,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":6.664823000000069,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":218.19959199999994,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":75.173135,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":27.597884000000022,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":18.47304299999996,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":4.763263000000052,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.54431,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":146.23636399999987,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":5.533337999999958,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":26.01176600000008,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":35.07896900000014,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":72.39975799999979,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":130.17408799999998,"failed":false}]]} \ No newline at end of file +{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3812.27516,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.17672299999958,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":42.62157499999989,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.673451999999997,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2185.81884,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":45.468795,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":69.34623899999997,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":11.403096000000005,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":43.37785599999984,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.334359999999947,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":45.94808499999999,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":9.13286000000005,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":234.617387,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":64.99855000000002,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":19.906604000000016,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":13.298808000000008,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":5.044317999999976,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":204.47145499999988,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":108.45520299999998,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":7.98414200000002,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":20.138449000000037,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":35.399114000000054,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":98.73430799999983,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":167.87446,"failed":false}],[":src/hooks/__tests__/useWalkRoute.test.ts",{"duration":139.1208529999999,"failed":false}]]} \ No newline at end of file diff --git a/apps/web/public/timetoleave_logo_header.png b/apps/web/public/timetoleave_logo_header.png deleted file mode 100644 index 715fb1a..0000000 Binary files a/apps/web/public/timetoleave_logo_header.png and /dev/null differ diff --git a/apps/web/src/app/event/BikeSection.tsx b/apps/web/src/app/event/BikeSection.tsx index d114071..321fe01 100644 --- a/apps/web/src/app/event/BikeSection.tsx +++ b/apps/web/src/app/event/BikeSection.tsx @@ -11,10 +11,18 @@ type BikeSectionProps = { bikeError: string | null | undefined; onRefresh?: () => void; className?: string; + forceVisible?: boolean; }; -const BikeSection: React.FC = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => { - if (!bikeRoute && !bikeLoading && !bikeError) { +const BikeSection: React.FC = ({ + bikeRoute, + bikeLoading, + bikeError, + onRefresh, + className = "", + forceVisible = false, +}) => { + if (!forceVisible && !bikeRoute && !bikeLoading && !bikeError) { return null; } @@ -24,6 +32,7 @@ const BikeSection: React.FC = ({ bikeRoute, bikeLoading, bikeE

Bicycle Route

+

Door-to-door route to the event

{onRefresh && (
)} - ) : null} + ) : ( +
+ Add origin and destination coordinates to calculate a bike route. +
+ )} ); diff --git a/apps/web/src/app/event/EventCard.tsx b/apps/web/src/app/event/EventCard.tsx index 09c9f8c..f16e2f9 100644 --- a/apps/web/src/app/event/EventCard.tsx +++ b/apps/web/src/app/event/EventCard.tsx @@ -60,17 +60,33 @@ export default function EventCard({ event, originStation }: EventCardProps) { const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings(); type TransportMode = "train" | "bike"; - const [activeMode, setActiveMode] = useState("train"); + const [requestedMode, setRequestedMode] = useState("train"); + const activeMode: TransportMode = !showBikeOption && requestedMode === "bike" ? "train" : requestedMode; - const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute?.duration || null, activeMode); + 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 (
-

Next stop

+

Next stop

{event.title}

@@ -78,36 +94,63 @@ export default function EventCard({ event, originStation }: EventCardProps) {
-

Destination

-

{event.destination}

+

Destination

+

{event.destination}

-

Appointment

-

{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}

+

Appointment

+

{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}

-
- - +
+ {modeOptions.map((option) => ( + + ))} +
+ +
+
+

Leave by

+

+ {departureTime ? format(departureTime, "HH:mm") : "Pending"} +

+
+
+

Arrive by

+

+ {arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")} +

+
+
+

Buffer

+

+ {arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""} +

+
@@ -127,7 +170,7 @@ export default function EventCard({ event, originStation }: EventCardProps) { )} {showBikeOption && activeMode === "bike" && ( - + )} {stops.length > 0 && ( diff --git a/apps/web/src/app/event/JourneyList.tsx b/apps/web/src/app/event/JourneyList.tsx index 1255f19..63f0967 100644 --- a/apps/web/src/app/event/JourneyList.tsx +++ b/apps/web/src/app/event/JourneyList.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Journey } from "@timetoleave/core"; +import type { Journey } from "@timetoleave/core"; import { formatTime } from "@timetoleave/core"; import LeaveByBadge from "./LeaveByBadge"; import { calculateCountdown } from "@timetoleave/core"; @@ -17,7 +17,7 @@ const JourneyList: React.FC = ({ journeys, eventTime, arrivalBufferMinutes, - className = "" + className = "", }) => { const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000); @@ -29,12 +29,21 @@ const JourneyList: React.FC = ({
    {journeys.map((journey) => { const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime(); + const departure = journey.rD ?? journey.sD; + const arrival = journey.rA ?? journey.sA; return ( -
  • +
  • - {formatTime(journey.sD)} + {formatTime(departure)} {journey.platform} {journey.delay > 0 && ( {`+${journey.delay}'`} @@ -46,11 +55,17 @@ const JourneyList: React.FC = ({
    - +
    -
    - {journey.changes > 0 ? Change(s): {journey.changes} : Direct} +
    + {journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"} + Arrives {formatTime(arrival)} + {arrivesTooLate && ( + + misses {arrivalBufferMinutes} min buffer + + )}
  • ); diff --git a/apps/web/src/app/event/TrainSection.tsx b/apps/web/src/app/event/TrainSection.tsx index 4486388..64180c2 100644 --- a/apps/web/src/app/event/TrainSection.tsx +++ b/apps/web/src/app/event/TrainSection.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Journey } from "@timetoleave/core"; +import type { Journey, WalkRoute } from "@timetoleave/core"; import { formatDateTime } from "@timetoleave/core"; import WalkingOption from "./WalkingOption"; import JourneyList from "./JourneyList"; @@ -18,7 +18,7 @@ type TrainSectionProps = { className?: string; arrivalBufferMinutes?: number; showWalkingOption?: boolean; - walkRoute?: import("@timetoleave/core").WalkRoute | null; + walkRoute?: WalkRoute | null; walkLoading?: boolean; walkError?: string | null; }; @@ -46,6 +46,11 @@ const TrainSection: React.FC = ({

    To {destName} {formatDateTime(eventTime)}

    + {(arrivalBufferMinutes ?? 0) > 0 && ( +

    + Target arrival: {arrivalBufferMinutes} min early +

    + )}
{onRefresh && (
{walkLoading ? ( -
+
) : walkError ? ( -
Error: {walkError}
+
Error: {walkError}
) : walkRoute ? (
@@ -55,12 +56,12 @@ const WalkingOption: React.FC = ({ walkRoute, walkLoading, w
{walkRoute.steps && walkRoute.steps.length > 0 && (
-

Steps

+

Steps

    {walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
  • - {step.name} - {step.instruction} + {step.name} + {step.instruction}
  • ))}
diff --git a/apps/web/src/hooks/__tests__/useDepartureTime.test.ts b/apps/web/src/hooks/__tests__/useDepartureTime.test.ts index a5535a3..a52e103 100644 --- a/apps/web/src/hooks/__tests__/useDepartureTime.test.ts +++ b/apps/web/src/hooks/__tests__/useDepartureTime.test.ts @@ -90,8 +90,8 @@ describe("useDepartureTime", () => { expect(result.current.mode).toBe("bike"); // Should account for bike duration + arrival buffer (default 5 min) - // Total travel time: 30 min (bike) + 5 min (buffer) + 30 min (bike back) = 65 min - const expectedDepartureTime = new Date(eventTimeMs - 65 * 60 * 1000); + // 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() ); diff --git a/apps/web/src/hooks/__tests__/useWalkRoute.test.ts b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts new file mode 100644 index 0000000..dc11e8a --- /dev/null +++ b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts @@ -0,0 +1,75 @@ +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 () => { + // Mock a failed API call + const originalFetch = global.fetch; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }) as unknown as Response); + + 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); + + // Restore original fetch + global.fetch = originalFetch; + }); + + it('should work with valid coordinates', async () => { + // Mock a successful API call + const mockRoute = { + distance: 500, + duration: 300, + steps: [ + { + name: 'Start walking', + distance: 500, + duration: 300, + instruction: 'Walk straight ahead' + } + ] + }; + + const originalFetch = global.fetch; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockRoute) + }) as unknown as Response); + + 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); + + // Restore original fetch + global.fetch = originalFetch; + }); +}); diff --git a/apps/web/src/hooks/useDepartureTime.ts b/apps/web/src/hooks/useDepartureTime.ts index 6ebc638..f147eb1 100644 --- a/apps/web/src/hooks/useDepartureTime.ts +++ b/apps/web/src/hooks/useDepartureTime.ts @@ -30,46 +30,29 @@ export function useDepartureTime( let arrivalTime: Date | null = null; let mode: "train" | "bike" | null = null; - // Handle train mode if (activeMode === "train" && validJourneys.length > 0) { - // Find journeys that arrive before or at the target arrival time const onTimeJourneys = validJourneys.filter(journey => - journey.sA.getTime() <= targetArrivalTime.getTime() + journey.rA.getTime() <= targetArrivalTime.getTime() ); if (onTimeJourneys.length > 0) { - // Pick the journey with the latest departure time const bestJourney = onTimeJourneys.reduce((latest, current) => - current.sD.getTime() > latest.sD.getTime() ? current : latest + current.rD.getTime() > latest.rD.getTime() ? current : latest ); - departureTime = new Date(bestJourney.sD); - arrivalTime = new Date(bestJourney.sA); + departureTime = new Date(bestJourney.rD); + arrivalTime = new Date(bestJourney.rA); mode = "train"; } } - // Handle bike mode if (activeMode === "bike" && bikeRoute !== null && bikeRoute > 0) { - // Convert bike route duration from seconds to milliseconds const bikeDurationMs = bikeRoute * 1000; - - // Calculate arrival time by subtracting bike duration from event time - const calculatedArrivalTime = new Date(eventTime); - calculatedArrivalTime.setTime(calculatedArrivalTime.getTime() - bikeDurationMs); - - // Calculate departure time by subtracting bike duration from calculated arrival time - const calculatedDepartureTime = new Date(calculatedArrivalTime); - calculatedDepartureTime.setTime(calculatedArrivalTime.getTime() - bikeDurationMs); - - // Calculate how much earlier we need to account for arrival buffer const totalBufferMs = arrivalBufferMinutes * 60 * 1000; - const totalTravelTimeMs = bikeDurationMs * 2 + totalBufferMs; + const targetArrivalMs = eventTime.getTime() - totalBufferMs; - departureTime = new Date(eventTime); - departureTime.setTime(eventTime.getTime() - totalTravelTimeMs); - - arrivalTime = new Date(calculatedArrivalTime); + departureTime = new Date(targetArrivalMs - bikeDurationMs); + arrivalTime = new Date(targetArrivalMs); mode = "bike"; } diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 8e9a2f9..0586f27 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,6 +1,7 @@ import type { GeocodeResult, BikeRoute, + WalkRoute, CalendarEvent, Journey, Station, @@ -44,7 +45,7 @@ export class ApiClient { fromLng: number, toLat: number, toLng: number, - ): Promise { + ): Promise { const url = buildUrl(this.baseUrl, "/api/bike-route", { fromLat: String(fromLat), fromLng: String(fromLng),