From ee7069d2e235a307adab4d7c459f5633f13a0294 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Antti=20Ellil=C3=A4?=
Date: Fri, 15 Nov 2024 20:11:43 +0200
Subject: [PATCH] fix: more graciously handle missing journey legs
---
src/components/LegendModal.tsx | 19 +-
src/lib/vr.ts | 54 +++--
src/pages/train/[date]/[train].tsx | 348 +++++++++++++++++------------
3 files changed, 254 insertions(+), 167 deletions(-)
diff --git a/src/components/LegendModal.tsx b/src/components/LegendModal.tsx
index 40f98bd..c8135ef 100644
--- a/src/components/LegendModal.tsx
+++ b/src/components/LegendModal.tsx
@@ -63,9 +63,12 @@ export const LegendModal = ({
Voit klikata paikkaa nähdäksesi sen varauksen asemalta toiselle. Voit myös rajata
asemaväliä liikuttamalla liukusäätimen päätepisteitä.
-
-
-
+
+
+
{heatmapEnabled ? (
)}
-
-
+
+
+
+
+
Vaihda keltaisesta väliväristä edistyneempään lämpökarttaan:
v ?? null),
+ placeType: z
+ .string()
+ .nullable()
+ .optional()
+ .transform((v) => v ?? null),
type: z.string(),
floorCount: z.number(),
order: z.number(),
@@ -255,23 +260,44 @@ export async function getTrainOnDate(date: string, trainNumber: string) {
const dep = train.timeTableRows[i * 2]!;
const arr = train.timeTableRows[i * 2 + 1]!;
const trainNumber = train.trainNumber.toString();
- const wagons = await getWagonMapData(
- dep.stationShortCode,
- arr.stationShortCode,
- new Date(dep.scheduledTime),
- trainNumber,
- auth.sessionId,
- auth.token
- );
- return {
- dep,
- arr,
- wagons,
- };
+ try {
+ const wagons = await getWagonMapData(
+ dep.stationShortCode,
+ arr.stationShortCode,
+ new Date(dep.scheduledTime),
+ trainNumber,
+ auth.sessionId,
+ auth.token
+ );
+ return {
+ dep,
+ arr,
+ wagons,
+ };
+ } catch (e) {
+ void error(
+ {
+ date,
+ train: trainNumber,
+ message: "Wagon map data fetching failed",
+ },
+ e
+ ).catch(console.error);
+ return {
+ dep,
+ arr,
+ wagons: null,
+ };
+ }
})
),
};
+ const nullWagons = newTrain.timeTableRows.reduce((a, tt) => a + (tt.wagons == null ? 1 : 0), 0);
+ if (nullWagons == newTrain.timeTableRows.length || nullWagons > 3) {
+ throw new Error("Too many null wagons!");
+ }
+
return newTrain;
}
diff --git a/src/pages/train/[date]/[train].tsx b/src/pages/train/[date]/[train].tsx
index 29da500..f966e03 100644
--- a/src/pages/train/[date]/[train].tsx
+++ b/src/pages/train/[date]/[train].tsx
@@ -1,10 +1,10 @@
import { LeftCircleOutlined, QuestionCircleOutlined, ReloadOutlined } from "@ant-design/icons";
-import { Button, Slider, message } from "antd";
+import { Button, Modal, Slider, message } from "antd";
import dayjs from "dayjs";
import { type GetServerSideProps, type InferGetServerSidePropsType } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
// @ts-expect-error no types exists
import { SvgLoader, SvgProxy } from "react-svgmt";
import { ZodError } from "zod";
@@ -70,6 +70,9 @@ export default function TrainPage({
const [timeRange, setTimeRange] = useState(initialRange ?? [0, 0]);
const [LModalOpen, setLModalOpen] = useState(false);
+ const [missingModalOpen, setMissingModalOpen] = useState(false);
+ // some weird antd modal hydration issues
+ useEffect(() => setMissingModalOpen(true), []);
const [selectedSeat, setSelectedSeat] = useState(initialSelectedSeat ?? null);
@@ -78,9 +81,13 @@ export default function TrainPage({
useEffect(() => {
// @ts-expect-error no types for globally available plausible function
// eslint-disable-next-line
- if (window.plausible) window.plausible("pageview", {
- u: (window.location.origin + window.location.pathname).split("/").filter((_,i) => i != 4).join("/")
- });
+ if (window.plausible)
+ window.plausible("pageview", {
+ u: (window.location.origin + window.location.pathname)
+ .split("/")
+ .filter((_, i) => i != 4)
+ .join("/"),
+ });
}, []);
useEffect(() => {
@@ -119,6 +126,25 @@ export default function TrainPage({
}
}, [router, train, stations, initialRange, initialSelectedSeat, timeRange, selectedSeat]);
+ const isInComplete = useMemo(
+ () =>
+ wagons
+ ? wagons.some((w) =>
+ w.floors.some((f) => f.seats.some((s) => s.status.some((ss) => ss === "missing")))
+ )
+ : false,
+ [wagons]
+ );
+ const missingRanges = useMemo(
+ () =>
+ wagons && stations
+ ? stations.map((_, i) =>
+ wagons.some((w) => w.floors.some((f) => f.seats.some((s) => s.status[i] === "missing")))
+ )
+ : [],
+ [wagons, stations]
+ );
+
useEffect(() => {
if (!initialSelectedSeat || !mainMapRef) return;
let cancelAnimation: (() => void) | null = null;
@@ -214,10 +240,13 @@ export default function TrainPage({
const color = seat
? {
+ missing: "#45475a",
unavailable: "#9399b2",
reserved: "#f38ba8",
open: "#a6e3a1",
}[seat.status[i + timeRange[0]!]!]
+ : isInComplete && missingRanges.slice(timeRange[0], timeRange[1])[i]
+ ? "#45475a"
: "#7f849c";
if (i === 0) leftHandle.style.setProperty("--handle-color", color);
@@ -235,7 +264,7 @@ export default function TrainPage({
const sliderEl = document.getElementsByClassName("ant-slider-track-1")[0] as HTMLElement;
if (sliderEl) sliderEl.style.background = styleStr;
- }, [timeRange, selectedSeat, wagons]);
+ }, [timeRange, selectedSeat, wagons, isInComplete, missingRanges]);
useEffect(() => {
router.prefetch(date ? `/?date=${date}` : "/").catch(console.error);
@@ -384,7 +413,7 @@ export default function TrainPage({
const bookedEcoSeats = ecoSeatObjs.filter((s) => s.status.includes("reserved")).length;
const percentageBookedEcoSeats =
ecoSeatObjs.reduce((a, c) => a + c.status.filter((s) => s === "reserved").length, 0) /
- ecoSeatObjs.reduce((a, c) => a + c.status.length, 0);
+ ecoSeatObjs.reduce((a, c) => a + c.status.filter((s) => s !== "missing").length, 0);
const siteTitle = `VenaaRauhassa - ${train.trainType}${train.trainNumber} - ${fiDate}`;
const description =
@@ -433,6 +462,21 @@ export default function TrainPage({
{messageContextHolder}
+ {isInComplete ? (
+ setMissingModalOpen(false)}
+ onCancel={() => setMissingModalOpen(false)}
+ footer={null}
+ >
+
+ Osa ominaisuuksista on poistettu käytöstä ja tiedot eivät välttämättä pidä paikkaansa.
+ Näet aikajanalla tumman harmaalla kohdat, joista tietoja ei ole saatavilla.
+
+
+ ) : null}
+
r === "unavailable");
- const allReserved = statusRange.every((r) => r === "reserved");
- const allOpen = statusRange.every((r) => r === "open");
+ const allUnavailable = statusRange
+ .filter((r) => r !== "missing")
+ .every((r) => r === "unavailable");
+ const allReserved = statusRange
+ .filter((r) => r !== "missing")
+ .every((r) => r === "reserved");
+ const allOpen = statusRange
+ .filter((r) => r !== "missing")
+ .every((r) => r === "open");
const extra = seat.productType === "EXTRA_CLASS_SEAT";
const restaurant = seat.productType === "SEAT_UPSTAIRS_RESTAURANT_WAGON";
@@ -675,7 +725,7 @@ export default function TrainPage({
20 *
(8 / 2 -
(statusRange.filter((r) => r === "reserved").length /
- statusRange.length) *
+ statusRange.filter((r) => r !== "missing").length) *
8)
);
return "#f9e2af";
@@ -743,165 +793,168 @@ export default function TrainPage({
mainMapRef={mainMapRef}
/>
-
-
{
- // @ts-expect-error no types for globally available plausible function
- // eslint-disable-next-line
- if (window.plausible) window.plausible("Find Seat");
+ {isInComplete ? null : (
+
+ {
+ // @ts-expect-error no types for globally available plausible function
+ // eslint-disable-next-line
+ if (window.plausible) window.plausible("Find Seat");
+
+ function getSeatGroup(floor: Floor, seat: Seat): Seat[] {
+ if (seat.services.includes("OPPOSITE")) {
+ const seatsInSection = floor.seats.filter(
+ (s) => s.section === seat.section && s.number !== seat.number
+ );
+ return seatsInSection;
+ } else {
+ const adjacentNumber = seat.number % 2 ? seat.number + 1 : seat.number - 1;
+ const adjacent = floor.seats.find((s) => s.number === adjacentNumber);
+ if (adjacent) return [adjacent];
+ return [];
+ }
+ }
- function getSeatGroup(floor: Floor, seat: Seat): Seat[] {
- if (seat.services.includes("OPPOSITE")) {
- const seatsInSection = floor.seats.filter(
- (s) => s.section === seat.section && s.number !== seat.number
+ function groupScore(group: Seat[]) {
+ if (!group.length) return 1;
+ // TODO: should probably use time occupied instead of just stations
+ return (
+ group.reduce(
+ (a, c) =>
+ a +
+ c.status.slice(timeRange[0], timeRange[1]).filter((s) => s === "open")
+ .length /
+ c.status.length,
+ 0
+ ) / group.length
);
- return seatsInSection;
- } else {
- const adjacentNumber = seat.number % 2 ? seat.number + 1 : seat.number - 1;
- const adjacent = floor.seats.find((s) => s.number === adjacentNumber);
- if (adjacent) return [adjacent];
- return [];
}
- }
- function groupScore(group: Seat[]) {
- if (!group.length) return 1;
- // TODO: should probably use time occupied instead of just stations
- return (
- group.reduce(
- (a, c) =>
- a +
- c.status.slice(timeRange[0], timeRange[1]).filter((s) => s === "open")
- .length /
- c.status.length,
- 0
- ) / group.length
+ const possibleSeats = wagons.flatMap((w) =>
+ w.floors.flatMap((f) =>
+ f.seats
+ .filter(
+ (s) =>
+ s.productType === "ECO_CLASS_SEAT" &&
+ s.type === "SEAT" &&
+ ["WHEELCHAIR", "COMPARTMENT", "PETS", "PET-COACH"].every(
+ (tSrv) => !s.services.some((srv) => srv.includes(tSrv))
+ ) &&
+ s.status.slice(timeRange[0], timeRange[1]).every((r) => r === "open")
+ )
+ .map((s) => {
+ const group = getSeatGroup(f, s);
+ return { ...s, wagon: w.number, group, groupScore: groupScore(group) };
+ })
+ )
);
- }
- const possibleSeats = wagons.flatMap((w) =>
- w.floors.flatMap((f) =>
- f.seats
- .filter(
- (s) =>
- s.productType === "ECO_CLASS_SEAT" &&
- s.type === "SEAT" &&
- ["WHEELCHAIR", "COMPARTMENT", "PETS", "PET-COACH"].every(
- (tSrv) => !s.services.some((srv) => srv.includes(tSrv))
- ) &&
- s.status.slice(timeRange[0], timeRange[1]).every((r) => r === "open")
- )
- .map((s) => {
- const group = getSeatGroup(f, s);
- return { ...s, wagon: w.number, group, groupScore: groupScore(group) };
+ if (!possibleSeats.length) {
+ messageApi
+ .open({
+ type: "error",
+ content: "Ei avoimia paikkoja junassa :(",
})
- )
- );
+ .then(
+ () => null,
+ () => null
+ );
+ return;
+ }
- if (!possibleSeats.length) {
- messageApi
- .open({
- type: "error",
- content: "Ei avoimia paikkoja junassa :(",
- })
- .then(
- () => null,
- () => null
- );
- return;
- }
+ const posToNum = (pos: string | null) => (pos === "WINDOW" ? 1 : 0);
- const posToNum = (pos: string | null) => (pos === "WINDOW" ? 1 : 0);
+ // TODO: this sorting and filtering is very primitive and the logic should be fine tuned to be more human-like
- // TODO: this sorting and filtering is very primitive and the logic should be fine tuned to be more human-like
+ possibleSeats.sort((s1, s2) => {
+ if (s1.group.length - s2.group.length) return s1.group.length - s2.group.length;
+ if (s2.groupScore - s1.groupScore) return s2.groupScore - s1.groupScore;
+ if (posToNum(s2.position) - posToNum(s1.position))
+ return posToNum(s2.position) - posToNum(s1.position);
+ return 0;
+ });
- possibleSeats.sort((s1, s2) => {
- if (s1.group.length - s2.group.length) return s1.group.length - s2.group.length;
- if (s2.groupScore - s1.groupScore) return s2.groupScore - s1.groupScore;
- if (posToNum(s2.position) - posToNum(s1.position))
- return posToNum(s2.position) - posToNum(s1.position);
- return 0;
- });
+ let criteria = 0;
+ let best = possibleSeats;
- let criteria = 0;
- let best = possibleSeats;
+ criteria = best[0]!.group.length;
+ best = best.filter((s) => s.group.length === criteria);
- criteria = best[0]!.group.length;
- best = best.filter((s) => s.group.length === criteria);
+ if (process.env.NODE_ENV === "development")
+ console.log("group length", criteria, best);
- if (process.env.NODE_ENV === "development")
- console.log("group length", criteria, best);
+ criteria = best[0]!.groupScore;
+ best = best.filter((s) => s.groupScore >= criteria - 0.0001);
- criteria = best[0]!.groupScore;
- best = best.filter((s) => s.groupScore >= criteria - 0.0001);
+ if (process.env.NODE_ENV === "development")
+ console.log("group score", criteria, best);
- if (process.env.NODE_ENV === "development")
- console.log("group score", criteria, best);
+ criteria = posToNum(best[0]!.position);
+ best = best.filter((s) => posToNum(s.position) === criteria);
- criteria = posToNum(best[0]!.position);
- best = best.filter((s) => posToNum(s.position) === criteria);
+ if (process.env.NODE_ENV === "development")
+ console.log("position", criteria, best);
- if (process.env.NODE_ENV === "development") console.log("position", criteria, best);
+ if (selectedSeat)
+ best = best.filter(
+ (s) => s.wagon !== selectedSeat[0] || s.number !== selectedSeat[1]
+ );
- if (selectedSeat)
- best = best.filter(
- (s) => s.wagon !== selectedSeat[0] || s.number !== selectedSeat[1]
- );
+ if (!best.length) {
+ messageApi
+ .open({
+ type: "info",
+ content: "Ei muita yhtä hyviä vaihtoehtoja",
+ })
+ .then(
+ () => null,
+ () => null
+ );
+ return;
+ }
+
+ const randomSeat = best[Math.floor(Math.random() * best.length)]!;
+ if (process.env.NODE_ENV === "development") console.log(randomSeat);
+
+ changeSeatSelection(randomSeat.wagon, randomSeat.number, true);
- if (!best.length) {
messageApi
.open({
- type: "info",
- content: "Ei muita yhtä hyviä vaihtoehtoja",
+ type: "success",
+ content: `Vaunu ${randomSeat.wagon} paikka ${randomSeat.number}`,
})
.then(
() => null,
() => null
);
- return;
- }
-
- const randomSeat = best[Math.floor(Math.random() * best.length)]!;
- if (process.env.NODE_ENV === "development") console.log(randomSeat);
-
- changeSeatSelection(randomSeat.wagon, randomSeat.number, true);
-
- messageApi
- .open({
- type: "success",
- content: `Vaunu ${randomSeat.wagon} paikka ${randomSeat.number}`,
- })
- .then(
- () => null,
- () => null
- );
- }}
- style={{
- fontWeight: "bold",
- height: "40px",
- fontSize: "16px",
- marginTop: "10px",
- }}
- >
- ✨ Löydä paikkasi ✨{" "}
-
- Beta
-
-
-
+ ✨ Löydä paikkasi ✨{" "}
+
+ Beta
+
+
+
+ )}
{
station: allStations[station.stationShortCode] ?? station.stationShortCode,
}));
- const rawWagons: (typeof train)["timeTableRows"][number]["wagons"][string][] = [];
+ const rawWagons: NonNullable<(typeof train)["timeTableRows"][number]["wagons"]>[string][] = [];
for (const timeTableRow of train.timeTableRows) {
+ if (!timeTableRow.wagons) continue;
for (const wagon of Object.values(timeTableRow.wagons)) {
if (!rawWagons.some((w) => w.number === wagon.number)) {
rawWagons.push(wagon);
@@ -1066,6 +1120,8 @@ export const getServerSideProps = (async (context) => {
number: place.number,
section: place.logicalSection,
status: train.timeTableRows.map((row) => {
+ // VR API IS VERY STUPID AND SOMETIMES IS JUST MISSING ENTIRE JOURNEY LEGS
+ if (!row.wagons) return "missing";
const rowWagon = Object.values(row.wagons).find(
(wagon2) => wagon2.number === wagon.number
);
@@ -1137,9 +1193,11 @@ export const getServerSideProps = (async (context) => {
{
date: context.params.date,
train: context.params.train,
- ...(e instanceof Error ? {
- message: e.message
- } : {}),
+ ...(e instanceof Error
+ ? {
+ message: e.message,
+ }
+ : {}),
url: "<" + getBaseURL() + context.resolvedUrl + ">",
},
e
@@ -1195,7 +1253,7 @@ export type Floor = {
export type Seat = {
number: number;
section: number;
- status: ("open" | "reserved" | "unavailable")[];
+ status: ("open" | "reserved" | "unavailable" | "missing")[];
productType: string;
type: string;
services: string[];