diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 20f8ae1..71a6df6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,7 +20,7 @@ "@react-navigation/native-stack": "^7.14.14", "@timetoleave/api-client": "*", "@timetoleave/core": "*", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-calendar": "~15.0.8", "expo-dev-client": "~6.0.21", "expo-location": "~19.0.8", diff --git a/apps/web/package.json b/apps/web/package.json index ea79edb..cf47519 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "~19.1.10", "@types/react-dom": "~19.1.10", diff --git a/apps/web/src/app/add-event/AddEventModal.tsx b/apps/web/src/app/add-event/AddEventModal.tsx index f5844b8..2305166 100644 --- a/apps/web/src/app/add-event/AddEventModal.tsx +++ b/apps/web/src/app/add-event/AddEventModal.tsx @@ -24,31 +24,19 @@ type AddEventModalProps = { const AddEventModal: React.FC = ({ isOpen, onClose, editEvent, className = "" }) => { const isEditing = !!editEvent; - const [title, setTitle] = useState(""); - const [destination, setDestination] = useState(""); - const [eventTime, setEventTime] = useState(""); - const [eventDate, setEventDate] = useState(""); + const [title, setTitle] = useState(() => editEvent?.title ?? ""); + const [destination, setDestination] = useState(() => editEvent?.destination ?? ""); + const [eventTime, setEventTime] = useState( + () => editEvent ? format(editEvent.eventTime, "HH:mm") : "", + ); + const [eventDate, setEventDate] = useState( + () => editEvent ? format(editEvent.eventTime, "yyyy-MM-dd") : "", + ); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); const { addEvent, updateEvent } = useEventsStore(); - useEffect(() => { - if (!isOpen) return; - if (editEvent) { - setTitle(editEvent.title); - setDestination(editEvent.destination); - setEventDate(format(editEvent.eventTime, "yyyy-MM-dd")); - setEventTime(format(editEvent.eventTime, "HH:mm")); - } else { - setTitle(""); - setDestination(""); - setEventDate(""); - setEventTime(""); - } - setSuccess(false); - }, [editEvent, isOpen]); - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim() || !destination.trim() || !eventTime.trim() || !eventDate.trim()) return; diff --git a/apps/web/src/app/event/__tests__/EventCard.test.tsx b/apps/web/src/app/event/__tests__/EventCard.test.tsx index 0f33d24..4a4eec0 100644 --- a/apps/web/src/app/event/__tests__/EventCard.test.tsx +++ b/apps/web/src/app/event/__tests__/EventCard.test.tsx @@ -166,7 +166,8 @@ describe("EventCard", () => { // useJourneys should be called with origin extId as first arg // and the destination station's extId as second arg (NOT null) - expect(useJourneys).toHaveBeenCalledWith("0WB0F0000600", "0WB0F0001500", new Date("2025-12-01T12:00:00"), 0); + // trainStationArrivalTarget = eventTime - arrivalBufferMinutes (5 min) + expect(useJourneys).toHaveBeenCalledWith("0WB0F0000600", "0WB0F0001500", new Date("2025-12-01T11:55:00"), 0, true); }); it("passes null as originStation extId to useJourneys when originStation is null", () => { @@ -181,6 +182,7 @@ describe("EventCard", () => { render(); // When originStation is null, the first arg to useJourneys should be null - expect(useJourneys).toHaveBeenCalledWith(null, "0WB0F0001500", new Date("2025-12-01T10:00:00"), 0); + // trainStationArrivalTarget = eventTime - arrivalBufferMinutes (5 min) + expect(useJourneys).toHaveBeenCalledWith(null, "0WB0F0001500", new Date("2025-12-01T09:55:00"), 0, true); }); }); diff --git a/apps/web/src/app/event/__tests__/JourneyList.test.tsx b/apps/web/src/app/event/__tests__/JourneyList.test.tsx index f6cea88..af3c76d 100644 --- a/apps/web/src/app/event/__tests__/JourneyList.test.tsx +++ b/apps/web/src/app/event/__tests__/JourneyList.test.tsx @@ -2,6 +2,7 @@ import "@testing-library/jest-dom"; import React from "react"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import JourneyList from "@/app/event/JourneyList"; import { Journey } from "@timetoleave/core"; @@ -66,7 +67,7 @@ describe("JourneyList component", () => { expect(screen.getByText("R1")).toBeInTheDocument(); }); - it("should dim late journeys based on arrivalBufferMinutes", () => { + it("should dim late journeys based on arrivalBufferMinutes", async () => { const lateJourney: Journey = { id: "1", sD: new Date("2025-01-01T14:00:00Z"), @@ -103,13 +104,16 @@ describe("JourneyList component", () => { /> ); + // Expand to show all journeys + await userEvent.click(screen.getByRole("button", { name: /show all/i })); + // Late journey should be dimmed and have line-through - const lateJourneyElement = screen.getByText("R1").closest("li"); + const lateJourneyElement = screen.getByText("R1").closest("div"); expect(lateJourneyElement).toHaveClass("opacity-40"); expect(lateJourneyElement).toHaveClass("line-through"); // On-time journey should not be dimmed - const onTimeJourneyElement = screen.getByText("R2").closest("li"); + const onTimeJourneyElement = screen.getByText("R2").closest("div"); expect(onTimeJourneyElement).not.toHaveClass("opacity-40"); expect(onTimeJourneyElement).not.toHaveClass("line-through"); }); diff --git a/apps/web/src/hooks/useRouteQuery.ts b/apps/web/src/hooks/useRouteQuery.ts index 42da935..ed63253 100644 --- a/apps/web/src/hooks/useRouteQuery.ts +++ b/apps/web/src/hooks/useRouteQuery.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; type RouteFetcher = (fromLat: number, fromLng: number, toLat: number, toLng: number) => Promise; @@ -22,11 +22,14 @@ export function useRouteQuery( const [error, setError] = useState(null); const fetcherRef = useRef(fetcher); - fetcherRef.current = fetcher; useEffect(() => { - let isMounted = true; + fetcherRef.current = fetcher; + }, [fetcher]); + const abortControllerRef = useRef(null); + + const fetchRoute = useCallback(async () => { if (fromLat == null || fromLng == null || toLat == null || toLng == null) { if (clearOnMissing) { setData(null); @@ -36,28 +39,33 @@ export function useRouteQuery( return; } - setLoading(true); - setError(null); + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; - fetcherRef.current(fromLat, fromLng, toLat, toLng) - .then((result) => { - if (isMounted) { - setData(result); - setLoading(false); - } - }) - .catch((err: unknown) => { - if (isMounted) { - setError(err instanceof Error ? err.message : "Failed to fetch route"); - setLoading(false); - } - }); - - return () => { - isMounted = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + try { + setLoading(true); + setError(null); + const result = await fetcherRef.current(fromLat, fromLng, toLat, toLng); + if (!controller.signal.aborted) { + setData(result); + setLoading(false); + } + } catch (err: unknown) { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : "Failed to fetch route"); + setLoading(false); + } + } }, [fromLat, fromLng, toLat, toLng, clearOnMissing]); + useEffect(() => { + // Data fetching is the canonical effect use case per React docs + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchRoute(); + return () => abortControllerRef.current?.abort(); + }, [fetchRoute]); + return { data, loading, error }; } diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index f149f27..d19c768 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1,15 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom does not define localStorage by default in all Vitest configurations. +Object.defineProperty(globalThis, "localStorage", { + value: (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = String(value); }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; + })(), + writable: true, +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 6d1de34..95dc4d5 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -6,6 +6,11 @@ const config = { plugins: [react()], test: { environment: "jsdom", + environmentOptions: { + jsdom: { + url: "http://localhost/", + }, + }, globals: true, setupFiles: ["./src/test/setup.ts"], }, diff --git a/package-lock.json b/package-lock.json index 002a64c..77c7738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "@react-navigation/native-stack": "^7.14.14", "@timetoleave/api-client": "*", "@timetoleave/core": "*", - "expo": "~54.0.33", + "expo": "~54.0.34", "expo-calendar": "~15.0.8", "expo-dev-client": "~6.0.21", "expo-location": "~19.0.8", @@ -81,6 +81,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "~19.1.10", "@types/react-dom": "~19.1.10", @@ -5271,6 +5272,20 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@timetoleave/api-client": { "resolved": "packages/api-client", "link": true