693 lines
38 KiB
React
693 lines
38 KiB
React
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>
|
||
);
|
||
}
|