Files
time_to_leave/oebb-planner.jsx
T
2026-05-09 09:30:52 +02:00

693 lines
38 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={{ fontFamily:"'DM Sans',sans-serif", background:"#080810", minHeight:"100vh", color:"#DCDCF0" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&family=Syne:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{background:#080810}
.card{background:#10101E;border:1px solid #1C1C30;border-radius:14px;overflow:hidden;transition:border-color .2s}
.card:hover{border-color:#2A2A44}
.jrow{display:flex;align-items:center;gap:10px;padding:9px 18px;border-bottom:1px solid #161626;transition:background .15s;cursor:default}
.jrow:last-child{border-bottom:none}
.jrow:hover{background:#121224}
.chip{display:inline-flex;align-items:center;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;font-family:'IBM Plex Mono',monospace}
.fab{display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif;font-weight:500;transition:all .15s}
.tab{padding:7px 14px;font-size:12px;font-weight:500;border:none;border-radius:6px;cursor:pointer;transition:all .15s;font-family:'DM Sans',sans-serif}
input[type=text],input[type=datetime-local],input[type=url]{width:100%;background:#14142A;border:1px solid #222236;border-radius:8px;padding:10px 14px;color:#DCDCF0;font-family:'DM Sans',sans-serif;font-size:14px;outline:none}
input:focus{border-color:${RED}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.fade-up{animation:fadeUp .35s ease}
.drop-zone{border:1.5px dashed #1E1E34;border-radius:10px;padding:24px;text-align:center;cursor:pointer;transition:border-color .2s,background .2s}
.drop-zone:hover{border-color:#44446A;background:#0E0E1E}
`}</style>
{/* ── Header ──────────────────────────────────────────── */}
<header style={{ padding:"16px 24px", display:"flex", justifyContent:"space-between", alignItems:"center", borderBottom:"1px solid #141424", background:"#0A0A18", position:"sticky", top:0, zIndex:20 }}>
<div style={{ display:"flex", alignItems:"center", gap:12 }}>
<div style={{ background:RED, borderRadius:8, padding:"5px 10px", fontFamily:"'Syne',sans-serif", fontWeight:800, fontSize:15, letterSpacing:.5, color:"#fff", userSelect:"none" }}>ÖBB</div>
<div>
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:17, letterSpacing:-.3, lineHeight:1.2 }}>Train Planner</div>
<div style={{ fontSize:11, color:"#38385A", fontFamily:"'IBM Plex Mono',monospace", marginTop:2 }}>
{originStn ? `${originStn.name}` : locState==="pending" ? "Locating…" : "Default station"}
</div>
</div>
</div>
<div style={{ display:"flex", alignItems:"center", gap:20 }}>
{/* Server status pill */}
<div style={{
display:"flex", alignItems:"center", gap:6, padding:"5px 10px",
background:"#0E0E1C", border:"1px solid #1C1C30", borderRadius:20,
}}>
<div style={{
width:6, height:6, borderRadius:"50%",
background: serverOnline===true?"#22C55E": serverOnline===false?"#EF4444":"#555",
animation: serverOnline===null?"blink 1.5s infinite":"none",
}}/>
<span style={{ fontSize:11, color: serverOnline===true?"#22C55E": serverOnline===false?"#EF4444":"#555", fontFamily:"'IBM Plex Mono',monospace" }}>
{serverOnline===true?"server online": serverOnline===false?"server offline":"checking…"}
</span>
</div>
<div style={{ textAlign:"right" }}>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:24, fontWeight:500, letterSpacing:-1, lineHeight:1 }}>{fmtTime(now)}</div>
<div style={{ display:"flex", alignItems:"center", gap:5, justifyContent:"flex-end", marginTop:3 }}>
<div style={{ width:6, height:6, borderRadius:"50%", background:isLive===true?"#22C55E":isLive===false?"#F59E0B":"#444", animation:isLive===null?"blink 1.5s infinite":"none" }}/>
<span style={{ fontSize:10, color:"#383858" }}>{isLive===true?"Live data":isLive===false?"Demo mode":"Connecting…"}</span>
</div>
</div>
</div>
</header>
{/* ── Server offline banner ────────────────────────────── */}
{serverOnline===false && (
<div style={{ background:"#120000", borderBottom:"1px solid #300000", padding:"10px 24px", fontSize:12, color:"#EF6060", display:"flex", gap:10, alignItems:"center" }}>
<span>🔌</span>
<span>Proxy server offline <strong>live ÖBB data and calendar import unavailable.</strong> Start it with:</span>
<code style={{ background:"#1E0000", padding:"2px 8px", borderRadius:4, fontFamily:"'IBM Plex Mono',monospace", color:"#FF9090" }}>cd server && npm start</code>
</div>
)}
<main style={{ padding:"20px 24px", maxWidth:900, margin:"0 auto", display:"flex", flexDirection:"column", gap:14 }}>
{/* ── Calendar Panel ──────────────────────────────────── */}
<div className="card fade-up">
<button
className="fab"
onClick={() => setCalPanel(p => !p)}
style={{ width:"100%", padding:"14px 18px", background:"transparent", color:"#DCDCF0", justifyContent:"space-between", borderRadius:0 }}
>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
<span style={{ fontSize:18 }}>📅</span>
<div style={{ textAlign:"left" }}>
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:15 }}>Calendar</div>
<div style={{ fontSize:11, color:"#44446A", marginTop:1 }}>
{calEventCount > 0
? `${calEventCount} event${calEventCount!==1?"s":""} imported`
: "Connect your ICS calendar to auto-import events with locations"}
</div>
</div>
</div>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
{calEventCount > 0 && (
<span className="chip" style={{ background:"#0A1A0A", color:"#22C55E" }}>
{calEventCount} events
</span>
)}
<span style={{ color:"#383858", fontSize:18, transform:calPanel?"none":"rotate(-90deg)", transition:"transform .2s" }}></span>
</div>
</button>
{calPanel && (
<div style={{ borderTop:"1px solid #161626", padding:"16px 18px" }}>
{/* Tabs */}
<div style={{ display:"flex", gap:6, marginBottom:16 }}>
{[["url","🔗 ICS URL"],["file","📁 Upload File"]].map(([id, label]) => (
<button key={id} className="tab" onClick={() => setCalTab(id)} style={{
background: calTab===id?"#1C1C34":"transparent",
color: calTab===id?"#DCDCF0":"#44446A",
border: calTab===id?"1px solid #2A2A48":"1px solid transparent",
}}>{label}</button>
))}
</div>
{calTab === "url" && (
<div>
<div style={{ fontSize:11, color:"#44446A", marginBottom:8, lineHeight:1.6 }}>
Paste your calendar's <strong style={{ color:"#8080AA" }}>secret ICS URL</strong>.
Works with Google Calendar, Apple Calendar, Outlook, and most other apps.<br/>
<span style={{ color:"#333352" }}>In Google Calendar: Settings → your calendar → "Secret address in iCal format"</span>
</div>
<div style={{ display:"flex", gap:8 }}>
<input
type="url"
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
value={calUrl}
onChange={e => setCalUrl(e.target.value)}
onKeyDown={e => e.key==="Enter" && connectCalendarURL()}
disabled={!serverOnline || calStatus==="loading"}
/>
<button
className="fab"
onClick={connectCalendarURL}
disabled={!serverOnline || !calUrl.trim() || calStatus==="loading"}
style={{ padding:"10px 18px", background:(!serverOnline||!calUrl.trim())?"#161626":RED, color:"#fff", fontSize:13, whiteSpace:"nowrap", opacity:(!serverOnline||!calUrl.trim())?0.5:1 }}
>
{calStatus==="loading" ? "…" : "Connect"}
</button>
</div>
</div>
)}
{calTab === "file" && (
<div>
<div style={{ fontSize:11, color:"#44446A", marginBottom:10 }}>
Export your calendar as an <strong style={{ color:"#8080AA" }}>.ics file</strong> and upload it here.
Works completely offline — the file is parsed by the local server.
</div>
<div
className="drop-zone"
onClick={() => serverOnline && fileRef.current?.click()}
style={{ opacity: serverOnline?1:0.4 }}
>
<div style={{ fontSize:28, marginBottom:8 }}>📂</div>
<div style={{ fontSize:13, color:"#44446A" }}>Click to choose an .ics file</div>
<div style={{ fontSize:11, color:"#2A2A44", marginTop:4 }}>Exported from Apple Calendar, Google, Outlook…</div>
</div>
<input ref={fileRef} type="file" accept=".ics,text/calendar" onChange={handleFileUpload} style={{ display:"none" }} />
</div>
)}
{/* Status message */}
{calMsg && (
<div style={{ marginTop:12, fontSize:12, padding:"8px 12px", borderRadius:8, background: calStatus==="ok"?"#0A180A": calStatus==="error"?"#1A0A0A":"#111128", color: calStatus==="ok"?"#4CAF50": calStatus==="error"?"#EF6060":"#8888CC" }}>
{calStatus==="ok"?"✓ ":calStatus==="error"?"✗ ":""}{calMsg}
</div>
)}
{!serverOnline && (
<div style={{ marginTop:10, fontSize:11, color:"#443030" }}>
⚠ Calendar import requires the server to be running.
</div>
)}
</div>
)}
</div>
{/* ── Section label ────────────────────────────────────── */}
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", padding:"2px 4px" }}>
<div style={{ fontSize:11, color:"#2A2A44", textTransform:"uppercase", letterSpacing:1.5 }}>
{events.length} event{events.length!==1?"s":""} · {WALK_MINS} min walk to station
</div>
{isLive===false && (
<span className="chip" style={{ background:"#1A1400", color:"#6A5000" }}>demo data</span>
)}
</div>
{/* ── Event Cards ────────────────────────────────────────── */}
{events.map(ev => {
const data = tripData[ev.id];
const best = data?.journeys ? bestJourney(data.journeys) : null;
const cd = best ? countdownInfo(best.rD, now) : null;
const lb = best ? leaveBy(best.rD) : null;
return (
<div key={ev.id} className="card fade-up">
{/* Event header */}
<div style={{ padding:"14px 18px", display:"flex", gap:12, justifyContent:"space-between", alignItems:"flex-start" }}>
<div style={{ flex:1 }}>
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:16, display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
{ev.title}
{ev.source==="calendar" && <span className="chip" style={{ background:"#0A100A", color:"#3A7A3A" }}>📅 calendar</span>}
{data?.demo && <span className="chip" style={{ background:"#181400", color:"#6A5000" }}>demo</span>}
</div>
<div style={{ fontSize:12, color:"#48486A", marginTop:5, display:"flex", gap:16, flexWrap:"wrap" }}>
<span>📍 {data?.destName ?? ev.destination}</span>
<span>🕐 {fmtTime(ev.eventTime)} · {ev.eventTime.toLocaleDateString("de-AT",{weekday:"short",day:"numeric",month:"short"})}</span>
</div>
</div>
<div style={{ display:"flex", alignItems:"flex-start", gap:8, flexShrink:0 }}>
{/* Leave-by box */}
{cd && lb && (
<div style={{ background:"#0C0C1C", border:`1px solid ${cd.urgent?cd.color+"55":"#1E1E34"}`, borderRadius:10, padding:"8px 14px", textAlign:"center", minWidth:96 }}>
<div style={{ fontSize:9, color:"#383858", textTransform:"uppercase", letterSpacing:1.2 }}>Leave by</div>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontWeight:600, fontSize:19, color:cd.color, lineHeight:1.2, marginTop:2 }}>{fmtTime(lb)}</div>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:11, color:cd.color, marginTop:2 }}>in {cd.label}</div>
</div>
)}
{data?.loading && !best && (
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:11, color:"#303050", marginTop:10, animation:"blink 1.5s infinite" }}>
searching…
</div>
)}
<button className="fab" onClick={() => removeEvent(ev.id)} style={{ background:"transparent", color:"#303050", fontSize:18, padding:"2px 6px", marginTop:4 }} title="Remove">×</button>
</div>
</div>
{/* Best train bar */}
{best && (
<div style={{ background:"#0C0C1A", borderTop:"1px solid #161626", borderBottom:"1px solid #161626", padding:"11px 18px", display:"flex", gap:16, alignItems:"center", flexWrap:"wrap" }}>
<div style={{ flex:1, minWidth:160 }}>
<div style={{ fontSize:9, color:"#2C2C48", textTransform:"uppercase", letterSpacing:1.6, marginBottom:5 }}>Next Best Train</div>
<div style={{ display:"flex", alignItems:"center", gap:7, flexWrap:"wrap" }}>
<span style={{ fontFamily:"'IBM Plex Mono',monospace", fontWeight:600, fontSize:15 }}>{best.trains.join(" → ")}</span>
<span className="chip" style={{ background:best.delay>0?"#250A0A":"#0A180A", color:delayColor(best.delay) }}>
{best.delay>0?`+${best.delay} min`:"on time"}
</span>
{best.cancelled && <span className="chip" style={{ background:"#200000", color:"#FF5050" }}>CANCELLED</span>}
{best.changes>0 && <span className="chip" style={{ background:"#101030", color:"#6868A0" }}>{best.changes}× change</span>}
</div>
</div>
<div style={{ display:"flex", gap:22, flexWrap:"wrap", alignItems:"center" }}>
{[
{ label:"DEP", val:fmtTime(best.rD), sub:best.delay>0?fmtTime(best.sD):null, color:best.delay>0?delayColor(best.delay):undefined },
{ label:"ARR", val:fmtTime(best.rA) },
{ label:"PLAT", val:best.platform, color:RED },
].map(item => (
<div key={item.label} style={{ textAlign:"center" }}>
<div style={{ fontSize:9, color:"#2C2C48", letterSpacing:1.6, textTransform:"uppercase" }}>{item.label}</div>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontWeight:600, fontSize:19, color:item.color, lineHeight:1.2 }}>{item.val}</div>
{item.sub && <div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:10, color:"#383858", textDecoration:"line-through" }}>{item.sub}</div>}
</div>
))}
</div>
</div>
)}
{/* Journey list */}
{data?.journeys && (
<div>
<div style={{ padding:"7px 18px 3px", fontSize:9, color:"#222238", textTransform:"uppercase", letterSpacing:2 }}>
All departures
</div>
{data.journeys.map(j => {
const lb2 = leaveBy(j.rD);
const gone = lb2 < now;
const isB = j.id === best?.id;
return (
<div key={j.id} className="jrow" style={{ opacity:gone?.35:1, background:isB?"#0E0E22":undefined }}>
<div style={{ minWidth:90, flexShrink:0 }}>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:12, fontWeight:isB?600:400, color:isB?"#C8C8E0":"#606080" }}>{j.trains[0]}</div>
{j.changes>0 && <div style={{ fontSize:10, color:"#303050" }}>{j.changes}× change</div>}
</div>
<div style={{ display:"flex", gap:16, flex:1, flexWrap:"wrap" }}>
{[{label:"LEAVE",val:fmtTime(lb2)},{label:"DEP",val:fmtTime(j.rD),delay:j.delay},{label:"ARR",val:fmtTime(j.rA)}].map(col => (
<div key={col.label}>
<div style={{ fontSize:9, color:"#242440", letterSpacing:1 }}>{col.label}</div>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontSize:12, color:col.delay>0?delayColor(col.delay):undefined }}>
{col.val}{col.delay>0&&<span style={{fontSize:10}}> +{col.delay}</span>}
</div>
</div>
))}
</div>
<div style={{ textAlign:"right", flexShrink:0 }}>
<div style={{ fontSize:9, color:"#242440" }}>PLAT</div>
<div style={{ fontFamily:"'IBM Plex Mono',monospace", fontWeight:600, fontSize:14, color:RED }}>{j.platform}</div>
</div>
{isB && !gone && <span style={{ fontSize:12, color:"#22C55E", flexShrink:0 }}>★</span>}
{j.cancelled && <span className="chip" style={{ background:"#1E0000", color:"#EF4444", flexShrink:0 }}>✕</span>}
</div>
);
})}
</div>
)}
</div>
);
})}
{/* ── Add manual event ─────────────────────────────────── */}
<button className="fab" onClick={() => setShowAdd(true)} style={{ background:"#10101E", border:"1px dashed #1E1E36", color:"#383858", padding:"14px 20px", fontSize:14, borderRadius:14, width:"100%" }}>
+ Add Event Manually
</button>
{lastUpdated && (
<div style={{ textAlign:"center", fontSize:11, color:"#242440", fontFamily:"'IBM Plex Mono',monospace", marginTop:4 }}>
Updated {fmtTime(lastUpdated)} · auto-refresh 60s ·{" "}
<span onClick={fetchAll} style={{ cursor:"pointer", color:"#343458", textDecoration:"underline" }}>refresh now</span>
</div>
)}
</main>
{/* ── Add Event Modal ─────────────────────────────────────── */}
{showAdd && (
<div onClick={e => e.target===e.currentTarget && setShowAdd(false)} style={{ position:"fixed", inset:0, background:"rgba(4,4,12,.8)", display:"flex", alignItems:"center", justifyContent:"center", padding:20, zIndex:200 }}>
<div style={{ background:"#10101E", border:"1px solid #1E1E32", borderRadius:16, padding:26, width:"100%", maxWidth:400 }} className="fade-up">
<div style={{ fontFamily:"'Syne',sans-serif", fontWeight:700, fontSize:18, marginBottom:22 }}>Add Event Manually</div>
{[
{ label:"EVENT TITLE", key:"title", type:"text", ph:"e.g. Client Meeting in Linz" },
{ label:"DESTINATION STATION", key:"destination", type:"text", ph:"e.g. Linz Hbf, Bregenz" },
{ label:"EVENT TIME", key:"eventTime", type:"datetime-local", ph:"" },
].map(f => (
<div key={f.key} style={{ marginBottom:14 }}>
<div style={{ fontSize:10, color:"#383858", letterSpacing:1.2, textTransform:"uppercase", marginBottom:6 }}>{f.label}</div>
<input type={f.type} placeholder={f.ph} value={form[f.key]} onChange={e => setForm(p => ({ ...p, [f.key]: e.target.value }))} />
</div>
))}
<div style={{ display:"flex", gap:10, marginTop:20 }}>
<button className="fab" onClick={() => setShowAdd(false)} style={{ flex:1, padding:"11px", background:"#181828", color:"#555570", fontSize:14 }}>Cancel</button>
<button className="fab" onClick={addManualEvent} style={{ flex:1, padding:"11px", background:RED, color:"#fff", fontSize:14 }}>Add Event</button>
</div>
</div>
</div>
)}
</div>
);
}