The project in one sentence
Part 1 — "Real-time" here means smart polling, not push
Tsx
// app/routes/__root.tsx
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 25,
refetchInterval: 1000 * 30,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
})
);
Typescript
// src/hooks/useDepartures.ts
export function useDepartures() {
return useQuery({
queryKey: ["departures", "saint-gratien-bnf"],
queryFn: fetchDepartures,
refetchInterval: 30000,
refetchIntervalInBackground: true,
staleTime: 25000,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
- staleTime answers the question "for how long is the cached data still considered fresh?". As long as the data isn't stale, a new component that mounts and requests the same queryKey (the array TanStack Query uses to identify and cache a specific request — ["departures", "saint-gratien-bnf"] in the hook above) gets the cache immediately, without going back to the network.
- refetchInterval answers a different question: "regardless of freshness, how often do I force a new fetch in the background?". This is what keeps the board running continuously without any user action.
Part 2 — Never let the shape of the external API leak up to the UI
Typescript
// src/lib/api.ts
export interface Departure {
id: string;
aimedDepartureTime: string;
expectedDepartureTime: string;
destination: string;
platform: string | null;
status: "onTime" | "delayed" | "cancelled" | "early" | "missed";
delay: number;
line: string;
isRealTime: boolean;
}
function parseSIRIResponse(data: SIRIResponse): Departure[] {
const visits =
data.Siri?.ServiceDelivery?.StopMonitoringDelivery?.[0]?.MonitoredStopVisit || [];
return visits
.map((visit, index): Departure => {
const journey = visit.MonitoredVehicleJourney;
const call = journey.MonitoredCall;
const aimed = new Date(call.AimedDepartureTime);
const expected = call.ExpectedDepartureTime
? new Date(call.ExpectedDepartureTime)
: aimed;
const delayMs = expected.getTime() - aimed.getTime();
const delay = Math.round(delayMs / 60000);
let status: Departure["status"] = "onTime";
if (call.DepartureStatus === "cancelled") status = "cancelled";
else if (call.DepartureStatus === "missed") status = "missed";
else if (delay > 2) status = "delayed";
else if (delay < -1) status = "early";
return {
id: `${journey.LineRef}-${index}-${call.AimedDepartureTime}`,
aimedDepartureTime: call.AimedDepartureTime,
expectedDepartureTime: call.ExpectedDepartureTime || call.AimedDepartureTime,
destination: journey.DestinationName || "BnF",
platform: call.DeparturePlatformName || call.ArrivalPlatformName || null,
status,
delay,
line: journey.PublishedLineName || "C14",
isRealTime: !!call.ExpectedDepartureTime,
};
})
.filter((d) => /* ... ne garder que les départs vers la bonne destination */ true)
.sort((a, b) => new Date(a.expectedDepartureTime).getTime() - new Date(b.expectedDepartureTime).getTime());
}
Part 3 — A countdown that doesn't depend on the network
Typescript
// src/hooks/useCountdown.ts
export function useCountdown(targetDate: string): number {
const [minutes, setMinutes] = useState(() => {
const now = new Date();
const target = new Date(targetDate);
return Math.max(0, Math.ceil((target.getTime() - now.getTime()) / 60000));
});
useEffect(() => {
const interval = setInterval(() => {
const now = new Date();
const target = new Date(targetDate);
const diff = target.getTime() - now.getTime();
setMinutes(Math.max(0, Math.ceil(diff / 60000)));
}, 1000);
return () => clearInterval(interval);
}, [targetDate]);
return minutes;
}
Part 4 — The real struggles, and why they were more instructive than the final code
What I'd do differently
- Pin versions from the very first commit (overrides, or a verified lockfile — the package-lock.json/pnpm-lock.yaml file that freezes the exact version of every dependency actually installed) on an ecosystem that's still moving fast, rather than leaving open ranges on recent packages.
- Always validate hydration with a real browser (headless Playwright is enough) before considering an SSR page "working" — the HTML returned by curl isn't enough to prove it.
- Commit a .gitignore before the first commit, not as an afterthought once you're wondering whether an API key almost ended up in the git history.
- Test business identifiers against the real API before hardcoding them, rather than starting from a value that "looks plausible".
- No automated tests so far on parseSIRIResponse, even though it's exactly the kind of pure function (JSON input → typed output, with business threshold rules) that lends itself very well to fast unit tests, with no network and no framework.