Files
time_to_leave/apps/web/src/app/event/EventCard.tsx
T
fegger 09b5e7725d Refactor departure time calculation to account for walk duration
Introduce `trainWalkDurationSeconds` in `useDepartureTime` hooks for both
mobile and web apps to filter train journeys based on total arrival time
including walking.

Add default origin station constants in core package and use them in mobile
store instead of returning null when no origin is saved.

Normalize HAFAS coordinates in destination station hooks to handle
large integer values.

Update `cleanLocation` to preserve full addresses with commas and remove
the 10KB request body limit on ICS parsing to support larger calendar
files.

Make rate limiting configurable via environment variables to handle
higher API fan-out from calendar event pages.
2026-05-14 12:17:09 +02:00

233 lines
8.9 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { format } from "date-fns";
import { useJourneys } from "@/hooks/useJourneys";
import { useDestinationStation } from "@/hooks/useDestinationStation";
import { useBikeRoute } from "@/hooks/useBikeRoute";
import { useWalkRoute } from "@/hooks/useWalkRoute";
import { useGeocode } from "@/hooks/useGeocode";
import { useClock } from "@/hooks/useClock";
import { useDepartureTime } from "@/hooks/useDepartureTime";
import { useReminderSettings } from "@/hooks/useReminderSettings";
import { useWienerLinien } from "@/hooks/useWienerLinien";
import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event, Station } from "@timetoleave/core";
import TrainSection from "./TrainSection";
import BikeSection from "./BikeSection";
import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge";
import AddEventModal from "@/app/add-event/AddEventModal";
interface EventCardProps {
event: Event;
originStation: Station | null;
}
export default function EventCard({ event, originStation }: EventCardProps) {
const [editOpen, setEditOpen] = useState(false);
const { removeEvent } = useEventsStore();
const handleRemove = () => {
if (window.confirm(`Remove "${event.title}"?`)) {
removeEvent(event.id);
}
};
const destStation = useDestinationStation(event.destination);
const destCoords = useGeocode(event.destination);
const {
bikeRoute,
loading: bikeLoading,
error: bikeError,
} = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
const {
walkRoute,
loading: walkLoading,
error: walkError,
} = useWalkRoute(
destStation.station?.lat,
destStation.station?.lng,
destCoords.coords?.lat,
destCoords.coords?.lng,
);
const {
stops,
departures,
loading: wlLoading,
error: wlError,
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
const finalWalkLookupPending =
showWalkingOption &&
destCoords.coords !== null &&
destStation.station?.lat != null &&
destStation.station?.lng != null &&
!walkRoute &&
!walkError;
const trainWalkDurationSeconds = showWalkingOption ? (walkRoute?.duration ?? 0) : 0;
const trainStationArrivalTarget = useMemo(
() => new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000 - trainWalkDurationSeconds * 1000),
[event.eventTime, arrivalBufferMinutes, trainWalkDurationSeconds],
);
const {
journeys,
loading: journeysLoading,
error: journeysError,
} = useJourneys(
finalWalkLookupPending ? null : (originStation?.extId ?? null),
finalWalkLookupPending ? null : (destStation.station?.extId ?? null),
trainStationArrivalTarget,
0,
true,
);
type TransportMode = "train" | "bike";
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
const activeMode: TransportMode = !showBikeOption && requestedMode === "bike" ? "train" : requestedMode;
const { departureTime, arrivalTime, mode: calculatedMode } = useDepartureTime(
event.eventTime,
journeys,
bikeRoute?.duration ?? null,
activeMode,
trainWalkDurationSeconds,
);
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-brand-fuchsia">Next stop</p>
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
</div>
<div className="flex items-start gap-2">
<CountdownBadge countdown={countdown} status={status} />
<button
onClick={() => setEditOpen(true)}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-brand-fuchsia/40 hover:text-white"
aria-label="Edit event"
>
Edit
</button>
<button
onClick={handleRemove}
className="rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1.5 text-xs font-medium text-[#F4F1EA]/60 transition-colors hover:border-red-500/50 hover:text-red-400"
aria-label="Remove event"
>
Remove
</button>
</div>
</div>
<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-brand-light/42">Destination</p>
<p className="mt-1 text-sm font-medium text-brand-light">{event.destination}</p>
</div>
<div className="brand-panel-soft rounded-xl p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Appointment</p>
<p className="mt-1 text-sm font-medium text-brand-light">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
</div>
</div>
<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-brand-fuchsia/70 bg-brand-purple/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
: "border-white/10 bg-white/[0.045] hover:border-brand-fuchsia/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-brand-pink/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
Active
</span>
)}
</span>
<span className="mt-1 block text-xs text-brand-light/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-brand-light/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-brand-light/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-brand-light/42">Buffer</p>
<p className="mt-1 font-semibold text-white">
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
</p>
</div>
</div>
<div className="space-y-4">
{activeMode === "train" && (
<TrainSection
journeys={journeys}
eventTime={event.eventTime}
destName={event.destination}
loading={journeysLoading}
error={journeysError}
arrivalBufferMinutes={arrivalBufferMinutes}
walkDurationSeconds={trainWalkDurationSeconds}
showWalkingOption={showWalkingOption}
walkRoute={walkRoute}
walkLoading={walkLoading}
walkError={walkError}
/>
)}
{showBikeOption && activeMode === "bike" && (
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} forceVisible />
)}
{stops.length > 0 && (
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
)}
</div>
</div>
<AddEventModal isOpen={editOpen} onClose={() => setEditOpen(false)} editEvent={event} />
</>
);
}