Update BikeSection and EventCard with arrival buffer and walk options
This commit is contained in:
@@ -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] |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -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}]]}
|
||||
{"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}]]}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 554 KiB |
@@ -11,10 +11,18 @@ type BikeSectionProps = {
|
||||
bikeError: string | null | undefined;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
forceVisible?: boolean;
|
||||
};
|
||||
|
||||
const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => {
|
||||
if (!bikeRoute && !bikeLoading && !bikeError) {
|
||||
const BikeSection: React.FC<BikeSectionProps> = ({
|
||||
bikeRoute,
|
||||
bikeLoading,
|
||||
bikeError,
|
||||
onRefresh,
|
||||
className = "",
|
||||
forceVisible = false,
|
||||
}) => {
|
||||
if (!forceVisible && !bikeRoute && !bikeLoading && !bikeError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,6 +32,7 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
|
||||
<p className="text-sm text-[#F4F1EA]/60">Door-to-door route to the event</p>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -67,7 +76,11 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-[#F4F1EA]/58">
|
||||
Add origin and destination coordinates to calculate a bike route.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,17 +60,33 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
|
||||
|
||||
type TransportMode = "train" | "bike";
|
||||
const [activeMode, setActiveMode] = useState<TransportMode>("train");
|
||||
const [requestedMode, setRequestedMode] = useState<TransportMode>("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 (
|
||||
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-\[0.24em\] text-\[#D946EF\]">Next stop</p>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Next stop</p>
|
||||
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
|
||||
</div>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
@@ -78,36 +94,63 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-\[0.18em\] text-\[#F4F1EA\]/42">Destination</p>
|
||||
<p className="mt-1 text-sm font-medium text-\[#F4F1EA\]">{event.destination}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/42">Destination</p>
|
||||
<p className="mt-1 text-sm font-medium text-[#F4F1EA]">{event.destination}</p>
|
||||
</div>
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-\[0.18em\] text-\[#F4F1EA\]/42">Appointment</p>
|
||||
<p className="mt-1 text-sm font-medium text-\[#F4F1EA\]">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#F4F1EA]/42">Appointment</p>
|
||||
<p className="mt-1 text-sm font-medium text-[#F4F1EA]">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`rounded-xl border p-3 text-left transition-all ${
|
||||
activeMode === option.id
|
||||
? "border-[#D946EF]/70 bg-[#B23CFF]/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
|
||||
: "border-white/10 bg-white/[0.045] hover:border-[#D946EF]/40 hover:bg-white/[0.07]"
|
||||
} ${option.disabled ? "cursor-not-allowed opacity-45 hover:border-white/10 hover:bg-white/[0.045]" : ""}`}
|
||||
onClick={() => {
|
||||
if (!option.disabled) {
|
||||
setRequestedMode(option.id);
|
||||
}
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-white">{option.label}</span>
|
||||
{activeMode === option.id && (
|
||||
<span className="rounded-full bg-[#FF2D8D]/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-[#F4F1EA]/58">{option.meta}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 rounded-xl border border-white/10 bg-black/16 p-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Leave by</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{departureTime ? format(departureTime, "HH:mm") : "Pending"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Arrive by</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/42">Buffer</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -127,7 +170,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
)}
|
||||
|
||||
{showBikeOption && activeMode === "bike" && (
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} forceVisible />
|
||||
)}
|
||||
|
||||
{stops.length > 0 && (
|
||||
|
||||
@@ -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<JourneyListProps> = ({
|
||||
journeys,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
className = ""
|
||||
className = "",
|
||||
}) => {
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
|
||||
@@ -29,12 +29,21 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
<ul className={`space-y-3 p-4 ${className}`}>
|
||||
{journeys.map((journey) => {
|
||||
const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime();
|
||||
const departure = journey.rD ?? journey.sD;
|
||||
const arrival = journey.rA ?? journey.sA;
|
||||
|
||||
return (
|
||||
<li key={journey.id} className={`rounded-xl border border-white/10 bg-white/[0.04] p-3 ${arrivesTooLate ? "opacity-40 line-through" : ""}`}>
|
||||
<li
|
||||
key={journey.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
arrivesTooLate || journey.cancelled
|
||||
? "border-[#FF2D8D]/18 bg-[#FF2D8D]/7 opacity-40 line-through"
|
||||
: "border-white/10 bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold text-white">{formatTime(journey.sD)}</span>
|
||||
<span className="font-semibold text-white">{formatTime(departure)}</span>
|
||||
<span className="ml-2 text-sm text-[#F4F1EA]/48">{journey.platform}</span>
|
||||
{journey.delay > 0 && (
|
||||
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
|
||||
@@ -46,11 +55,17 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<LeaveByBadge countdown={calculateCountdown(journey.sD)} />
|
||||
<LeaveByBadge countdown={calculateCountdown(departure)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-[#F4F1EA]/64">
|
||||
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#F4F1EA]/64">
|
||||
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
||||
<span>Arrives {formatTime(arrival)}</span>
|
||||
{arrivesTooLate && (
|
||||
<span className="font-medium text-[#FF2D8D]">
|
||||
misses {arrivalBufferMinutes} min buffer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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<TrainSectionProps> = ({
|
||||
<p className="text-sm text-[#F4F1EA]/66">
|
||||
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||
</p>
|
||||
{(arrivalBufferMinutes ?? 0) > 0 && (
|
||||
<p className="mt-1 text-xs text-[#D946EF]">
|
||||
Target arrival: {arrivalBufferMinutes} min early
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -64,8 +69,8 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
||||
{showWalkingOption && walkRoute && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-white/10">
|
||||
{showWalkingOption && (walkRoute || walkLoading || walkError) && (
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,11 +19,12 @@ const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, w
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mt-4 overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Walking Option</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Final Walk</h3>
|
||||
<p className="text-sm text-[#F4F1EA]/60">From arrival station to destination</p>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -34,11 +35,11 @@ const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, w
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{walkLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="py-4 text-center">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : walkError ? (
|
||||
<div className="text-center py-4 text-[#FF2D8D]">Error: {walkError}</div>
|
||||
<div className="py-4 text-center text-[#FF2D8D]">Error: {walkError}</div>
|
||||
) : walkRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
@@ -55,12 +56,12 @@ const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, w
|
||||
</div>
|
||||
{walkRoute.steps && walkRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 font-medium text-\[#F4F1EA\]">Steps</h4>
|
||||
<h4 className="mb-2 font-medium text-[#F4F1EA]">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-\[#D946EF\]">{step.name}</span>
|
||||
<span className="ml-2 text-\[#F4F1EA\]/70">{step.instruction}</span>
|
||||
<span className="font-medium text-[#D946EF]">{step.name}</span>
|
||||
<span className="ml-2 text-[#F4F1EA]/70">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BikeRoute> {
|
||||
): Promise<WalkRoute> {
|
||||
const url = buildUrl(this.baseUrl, "/api/bike-route", {
|
||||
fromLat: String(fromLat),
|
||||
fromLng: String(fromLng),
|
||||
|
||||
Reference in New Issue
Block a user