09b5e7725d
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.
233 lines
8.9 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|