In Google Calendar: Settings → your calendar → "Secret address in iCal format"
import { useState, useEffect, useCallback, useRef } from "react"; // ───────────────────────────────────────────────────────────── // Config — point at the Node proxy server // ───────────────────────────────────────────────────────────── const PROXY = "http://localhost:3001"; // ───────────────────────────────────────────────────────────── // ÖBB HAFAS API (now via the proxy — no more CORS errors) // ───────────────────────────────────────────────────────────── const HAFAS_BASE = { auth: { aid: "OWDL4fE4ixNiPBBm", type: "AID" }, client: { id: "OeBB", type: "AND", name: "Scotty", v: 100000000 }, ver: "1.57", lang: "de", formatted: false, }; async function hafas(svcReqL) { const r = await fetch(`${PROXY}/api/hafas`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...HAFAS_BASE, svcReqL }), signal: AbortSignal.timeout(10_000), }); if (!r.ok) throw new Error(`Proxy HTTP ${r.status}`); return r.json(); } async function stationByName(name) { const d = await hafas([{ meth: "LocMatch", req: { input: { loc: { type: "S", name: `${name}?` }, maxLoc: 3, field: "S" } } }]); return d.svcResL?.[0]?.res?.match?.locL ?? []; } async function stationByCoord(lat, lng) { const d = await hafas([{ meth: "LocGeoPos", req: { ring: { cCrd: { x: Math.round(lng * 1e6), y: Math.round(lat * 1e6) }, maxDist: 2000 }, getPOIs: false, getStops: true, maxLoc: 1, } }]); return d.svcResL?.[0]?.res?.locL ?? []; } function hafasDateTime(dateStr, timeStr) { if (!dateStr || !timeStr) return null; let t = timeStr, off = 0; if (t.length > 6) { off = parseInt(t.slice(0, t.length - 6)); t = t.slice(-6); } return new Date(+dateStr.slice(0,4), +dateStr.slice(4,6)-1, +dateStr.slice(6,8)+off, +t.slice(0,2), +t.slice(2,4), +t.slice(4,6)); } function parseJourney(con, prodL) { const dd = con.dep?.dDateS ?? ""; const sD = hafasDateTime(dd, con.dep?.dTimeS); const rD = con.dep?.dTimeR ? hafasDateTime(dd, con.dep?.dTimeR) : sD; const ad = con.arr?.aDateS ?? dd; const sA = hafasDateTime(ad, con.arr?.aTimeS); const rA = con.arr?.aTimeR ? hafasDateTime(ad, con.arr?.aTimeR) : sA; const trains = (con.secL ?? []) .filter(s => s.type === "JNY" && s.jny?.prodX != null) .map(s => prodL?.[s.jny.prodX]?.nameS ?? prodL?.[s.jny.prodX]?.name ?? "") .filter(Boolean); return { id: String(sD?.getTime() ?? Math.random()), sD, rD, sA, rA, delay: sD && rD ? Math.max(0, Math.round((rD - sD) / 60000)) : 0, platform: con.dep?.dPlatfR ?? con.dep?.dPlatfS ?? "–", changes: con.chgCnt ?? 0, trains: trains.length ? trains : ["Train"], cancelled: !!con.dep?.dCncl, }; } async function findJourneys(fromExtId, toExtId, when) { const d = when; const ds = `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,"0")}${String(d.getDate()).padStart(2,"0")}`; const ts = `${String(d.getHours()).padStart(2,"0")}${String(d.getMinutes()).padStart(2,"0")}00`; const data = await hafas([{ meth: "TripSearch", req: { depLocL: [{ type: "S", extId: fromExtId }], arrLocL: [{ type: "S", extId: toExtId }], outDate: ds, outTime: ts, outFrwd: true, numF: 5, maxChg: 2, jnyFltrL: [{ type: "PROD", mode: "INC", value: "1023" }], getTariff: false, getPasslist: false, } }]); const res = data.svcResL?.[0]?.res; const prodL = res?.common?.prodL ?? []; return (res?.outConL ?? []).map(c => parseJourney(c, prodL)); } // ───────────────────────────────────────────────────────────── // Calendar API // ───────────────────────────────────────────────────────────── async function calendarFromURL(icsUrl) { const r = await fetch(`${PROXY}/api/calendar?url=${encodeURIComponent(icsUrl)}`, { signal: AbortSignal.timeout(15_000), }); if (!r.ok) { const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` })); throw new Error(err.error ?? `HTTP ${r.status}`); } return r.json(); // [{id, title, destination, eventTime, source}] } async function calendarFromFile(icsText) { const r = await fetch(`${PROXY}/api/calendar/parse`, { method: "POST", headers: { "Content-Type": "text/calendar" }, body: icsText, signal: AbortSignal.timeout(10_000), }); if (!r.ok) { const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` })); throw new Error(err.error ?? `HTTP ${r.status}`); } return r.json(); } // ───────────────────────────────────────────────────────────── // Demo Data (used when server is offline) // ───────────────────────────────────────────────────────────── function makeDemoJourneys(walkMins) { const TYPES = ["RJX","IC","REX","S","D","EC"]; const PLATS = ["1","2","3","4","5","6","7","8","2A","3B","4C"]; const now = new Date(); let t = new Date(now.getTime() + (walkMins + 8) * 60000); t.setSeconds(0, 0); t.setMinutes(Math.ceil(t.getMinutes() / 5) * 5); return Array.from({ length: 5 }, (_, i) => { if (i > 0) t = new Date(t.getTime() + (12 + Math.random() * 28) * 60000); const type = TYPES[i % TYPES.length]; const num = 100 + Math.floor(Math.random() * 899); const durMs = (30 + Math.random() * 95) * 60000; const delay = Math.random() > 0.72 ? Math.ceil(Math.random() * 9) : 0; const rD = new Date(t.getTime() + delay * 60000); return { id: `demo-${i}`, sD: new Date(t), rD, sA: new Date(t.getTime() + durMs), rA: new Date(rD.getTime() + durMs), delay, platform: PLATS[Math.floor(Math.random() * PLATS.length)], changes: i === 2 ? 1 : 0, trains: [`${type} ${num}`], cancelled: false, }; }); } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── const WALK_MINS = 12; const RED = "#EE2127"; const fmtTime = d => d?.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" }) ?? "–"; const leaveBy = rD => new Date(rD.getTime() - WALK_MINS * 60000); function countdownInfo(rD, now) { const ms = leaveBy(rD) - now; if (ms < 0) return { label: "GONE", color: "#EF4444", urgent: true }; const m = Math.floor(ms / 60000); if (m < 2) return { label: `${Math.ceil(ms/1000)}s`, color: "#EF4444", urgent: true }; if (m < 15) return { label: `${m} min`, color: "#F59E0B", urgent: true }; const h = Math.floor(m / 60); return { label: h ? `${h}h ${m%60}m` : `${m} min`, color: "#22C55E", urgent: false }; } const delayColor = d => d > 5 ? "#EF4444" : d > 2 ? "#F59E0B" : "#22C55E"; // ───────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────── const SAMPLE_EVENTS = [ { id: "s1", title: "Meeting in Graz", destination: "Graz Hbf", eventTime: new Date(Date.now() + 3.5*3600000), source: "manual" }, { id: "s2", title: "Conference in Salzburg", destination: "Salzburg Hbf", eventTime: new Date(Date.now() + 8*3600000), source: "manual" }, ]; export default function OebbPlanner() { // ── Core state ───────────────────────────────────────────── const [events, setEvents] = useState(SAMPLE_EVENTS); const [location, setLocation] = useState(null); const [locState, setLocState] = useState("pending"); const [originStn, setOriginStn] = useState(null); const [tripData, setTripData] = useState({}); const [isLive, setIsLive] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); const [now, setNow] = useState(new Date()); const [showAdd, setShowAdd] = useState(false); const [form, setForm] = useState({ title:"", destination:"", eventTime:"" }); // ── Server / calendar state ──────────────────────────────── const [serverOnline, setServerOnline] = useState(null); // null|true|false const [calPanel, setCalPanel] = useState(true); // collapsed? const [calTab, setCalTab] = useState("url"); // "url"|"file" const [calUrl, setCalUrl] = useState(""); const [calStatus, setCalStatus] = useState(null); // null|loading|ok|error const [calMsg, setCalMsg] = useState(""); const fileRef = useRef(null); const apiOk = useRef(null); // ── Clock ────────────────────────────────────────────────── useEffect(() => { const id = setInterval(() => setNow(new Date()), 10_000); return () => clearInterval(id); }, []); // ── Server health check ──────────────────────────────────── const checkServer = useCallback(async () => { try { const r = await fetch(`${PROXY}/api/health`, { signal: AbortSignal.timeout(3000) }); const ok = r.ok && (await r.json()).ok; setServerOnline(ok); return ok; } catch { setServerOnline(false); return false; } }, []); useEffect(() => { checkServer(); const id = setInterval(checkServer, 30_000); return () => clearInterval(id); }, [checkServer]); // ── Geolocation ──────────────────────────────────────────── useEffect(() => { if (!navigator.geolocation) { setLocState("denied"); return; } navigator.geolocation.getCurrentPosition( p => { setLocation({ lat: p.coords.latitude, lng: p.coords.longitude }); setLocState("granted"); }, () => setLocState("denied"), { timeout: 10000, maximumAge: 300_000 } ); }, []); // ── Find origin station ──────────────────────────────────── useEffect(() => { if (locState === "pending") return; (async () => { if (location && serverOnline) { try { const stns = await stationByCoord(location.lat, location.lng); if (stns.length > 0) { setOriginStn({ name: stns[0].name, extId: stns[0].extId }); apiOk.current = true; setIsLive(true); return; } } catch { /* fall through */ } } apiOk.current = serverOnline ? null : false; setIsLive(serverOnline ? null : false); setOriginStn({ name: "Wien Hauptbahnhof", extId: "1190100" }); })(); }, [location, locState, serverOnline]); // ── Fetch journeys ───────────────────────────────────────── const fetchAll = useCallback(async () => { if (!originStn) return; for (const ev of events) { setTripData(p => ({ ...p, [ev.id]: { ...(p[ev.id]??{}), loading: true } })); const t0 = new Date(); const search = new Date(ev.eventTime.getTime() - 2*3600000); const when = search < t0 ? t0 : search; try { if (apiOk.current === false) throw new Error("demo"); const destList = await stationByName(ev.destination); if (!destList.length) throw new Error("no station"); const dest = destList[0]; const journeys = await findJourneys(originStn.extId, dest.extId, when); apiOk.current = true; setIsLive(true); setTripData(p => ({ ...p, [ev.id]: { journeys, destName: dest.name, demo: false, loading: false } })); } catch { if (apiOk.current !== true) { apiOk.current = false; setIsLive(false); } setTripData(p => ({ ...p, [ev.id]: { journeys: makeDemoJourneys(WALK_MINS), destName: ev.destination, demo: true, loading: false } })); } } setLastUpdated(new Date()); }, [events, originStn]); useEffect(() => { if (originStn) fetchAll(); }, [originStn, fetchAll]); useEffect(() => { const id = setInterval(fetchAll, 60_000); return () => clearInterval(id); }, [fetchAll]); // ── Calendar import helpers ──────────────────────────────── const mergeCalendarEvents = useCallback(calEvents => { const parsed = calEvents.map(e => ({ ...e, eventTime: new Date(e.eventTime), })); setEvents(prev => [ ...prev.filter(e => e.source !== "calendar"), ...parsed, ]); }, []); const connectCalendarURL = async () => { if (!calUrl.trim()) return; setCalStatus("loading"); setCalMsg(""); try { const evs = await calendarFromURL(calUrl.trim()); mergeCalendarEvents(evs); setCalStatus("ok"); setCalMsg(`${evs.length} event${evs.length!==1?"s":""} imported (next 14 days, locations only)`); setCalPanel(false); } catch (err) { setCalStatus("error"); setCalMsg(err.message); } }; const handleFileUpload = async e => { const file = e.target.files?.[0]; if (!file) return; setCalStatus("loading"); setCalMsg(""); try { const text = await file.text(); const evs = await calendarFromFile(text); mergeCalendarEvents(evs); setCalStatus("ok"); setCalMsg(`${evs.length} event${evs.length!==1?"s":""} imported from ${file.name}`); setCalPanel(false); } catch (err) { setCalStatus("error"); setCalMsg(err.message); } e.target.value = ""; }; const addManualEvent = () => { if (!form.title || !form.destination || !form.eventTime) return; setEvents(p => [...p, { id: Date.now().toString(), title: form.title, destination: form.destination, eventTime: new Date(form.eventTime), source: "manual", }]); setForm({ title:"", destination:"", eventTime:"" }); setShowAdd(false); }; const removeEvent = id => setEvents(p => p.filter(e => e.id !== id)); const bestJourney = journeys => journeys?.find(j => leaveBy(j.rD) > now) ?? journeys?.[0]; const calEventCount = events.filter(e => e.source === "calendar").length; // ───────────────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────────────── return (
cd server && npm start