diff --git a/server/index.ts b/server/index.ts index 98b2c9d..12dfbff 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ import jellyfin from './routes/jellyfin' import navidrome from './routes/navidrome' import romm from './routes/romm' import weather from './routes/weather' +import transit from './routes/transit' const app = express() const PORT = Number(process.env.PORT ?? 3001) @@ -39,6 +40,7 @@ app.use('/api/jellyfin', jellyfin) app.use('/api/navidrome', navidrome) app.use('/api/romm', romm) app.use('/api/weather', weather) +app.use('/api/transit', transit) // Serve built frontend in production only if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/transit.ts b/server/routes/transit.ts new file mode 100644 index 0000000..b3e3955 --- /dev/null +++ b/server/routes/transit.ts @@ -0,0 +1,36 @@ +import { Router } from 'express' +import axios from 'axios' + +const router = Router() + +router.get('/departures', async (_req, res) => { + try { + const stop = process.env.TRANSIT_STOP_VAG ?? 'SCHW' + const response = await axios.get( + `https://start.vag.de/dm/api/abfahrten.json/vag/${stop}?timespan=90&limitcount=25`, + { timeout: 8000 } + ) + + const d = response.data + const departures = (d.Abfahrten ?? []).map((a: Record) => ({ + line: a.Linienname, + direction: a.Richtungstext, + scheduledTime: a.AbfahrtszeitSoll, + realtimeTime: a.AbfahrtszeitIst, + realtime: a.Prognose, + product: a.Produkt, + platform: a.HaltesteigText, + })) + + res.json({ + stop: d.Haltestellenname ?? stop, + notices: d.Sonderinformationen ?? [], + departures, + }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + res.status(500).json({ error: msg }) + } +}) + +export default router diff --git a/server/routes/weather.ts b/server/routes/weather.ts index 0ad1070..cc299ea 100644 --- a/server/routes/weather.ts +++ b/server/routes/weather.ts @@ -13,20 +13,32 @@ router.get('/current', async (_req, res) => { const response = await axios.get( `https://wttr.in/${encodeURIComponent(location)}?format=j1`, - { headers: { 'Accept': 'application/json' }, timeout: 8000 } + { headers: { Accept: 'application/json' }, timeout: 8000 } ) - const c = response.data.current_condition[0] - const area = response.data.nearest_area?.[0] + const data = response.data + const c = data.current_condition[0] + const area = data.nearest_area?.[0] + const today = data.weather?.[0] + + const hourly = (today?.hourly ?? []).map((h: Record) => ({ + hour: Math.floor(Number(h.time) / 100), + tempC: Number(h.tempC), + code: Number(h.weatherCode), + desc: (h.weatherDesc as { value: string }[])[0]?.value ?? '', + })) res.json({ - tempC: Number(c.temp_C), - feelsLikeC: Number(c.FeelsLikeC), - humidity: Number(c.humidity), - windKmph: Number(c.windspeedKmph), - desc: c.weatherDesc[0].value as string, - code: Number(c.weatherCode), - city: area?.areaName?.[0]?.value ?? location, + tempC: Number(c.temp_C), + feelsLikeC: Number(c.FeelsLikeC), + humidity: Number(c.humidity), + windKmph: Number(c.windspeedKmph), + desc: c.weatherDesc[0].value as string, + code: Number(c.weatherCode), + city: area?.areaName?.[0]?.value ?? location, + todayMin: Number(today?.mintempC ?? c.temp_C), + todayMax: Number(today?.maxtempC ?? c.temp_C), + hourly, }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error' diff --git a/src/App.tsx b/src/App.tsx index fb07e40..528ed1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' -import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { Header } from './components/Header' import { AppsPage } from './pages/AppsPage' +import { MobileHome } from './pages/MobileHome' import { dashboardSections } from './config/dashboard' +import { useIsMobile } from './hooks/useIsMobile' function DashboardPage() { const [collapsed, setCollapsed] = useState>(new Set()) @@ -37,6 +39,12 @@ function DashboardPage() { ) } + +function RootRedirect() { + const isMobile = useIsMobile() + return +} + export default function App() { return ( @@ -45,8 +53,10 @@ export default function App() {
- } /> - } /> + } /> + } /> + } /> + } />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b92ab60..2dd8df6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -54,8 +54,9 @@ export function Header() {
{weather && (
diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..344eda8 --- /dev/null +++ b/src/hooks/useIsMobile.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from 'react' + +export function useIsMobile(breakpoint = 640) { + const [isMobile, setIsMobile] = useState( + () => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches + ) + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`) + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener('change', handler) + return () => mql.removeEventListener('change', handler) + }, [breakpoint]) + + return isMobile +} diff --git a/src/index.css b/src/index.css index 53e4096..04fe691 100644 --- a/src/index.css +++ b/src/index.css @@ -343,3 +343,223 @@ header { ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Mobile Home ── */ +.mobile-home { + display: flex; + flex-direction: column; + gap: 14px; + padding-bottom: 24px; +} + +.mobile-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + animation: fadeUp 0.4s ease both; +} + +.mobile-loading { + color: var(--muted); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + min-height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Weather */ +.mobile-weather-card {} + +.mobile-weather-main { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; +} + +.mobile-weather-emoji { font-size: 48px; line-height: 1; } + +.mobile-temp { + font-family: 'JetBrains Mono', monospace; + font-size: 42px; + font-weight: 500; + line-height: 1; + background: linear-gradient(135deg, var(--text), var(--text2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.mobile-weather-desc { + font-size: 13px; + color: var(--text2); + margin-top: 4px; +} + +.mobile-weather-minmax { + margin-left: auto; + text-align: right; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + display: flex; + flex-direction: column; + gap: 2px; + color: var(--text2); +} + +.mobile-weather-stats { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--muted); + margin-bottom: 14px; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.mobile-stat-sep { color: var(--border); } + +.mobile-hourly-scroll { + display: flex; + gap: 4px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; +} +.mobile-hourly-scroll::-webkit-scrollbar { display: none; } + +.mobile-hour-slot { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + background: var(--surface2); + min-width: 52px; +} + +.mobile-hour-now { + background: rgba(79,142,247,0.15); + border: 1px solid rgba(79,142,247,0.3); +} + +.mobile-hour-time { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--muted); +} + +.mobile-hour-now .mobile-hour-time { color: var(--accent); } + +.mobile-hour-emoji { font-size: 18px; } + +.mobile-hour-temp { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 500; + color: var(--text); +} + +/* Transit */ +.mobile-transit-card {} + +.mobile-transit-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 12px; +} + +.mobile-transit-stop { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.mobile-transit-updated { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--muted); +} + +.mobile-refresh-btn { + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text2); + border-radius: 6px; + width: 30px; + height: 30px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} +.mobile-refresh-btn:hover { color: var(--text); } + +.mobile-notices { + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.mobile-notice { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--yellow); + background: rgba(251,191,36,0.08); + border: 1px solid rgba(251,191,36,0.2); + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; +} + +.mobile-departures-list { + display: flex; + flex-direction: column; +} + +.mobile-departure-row { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--border); +} +.mobile-departure-row:last-child { border-bottom: none; } + +.mobile-line-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 700; + color: #fff; + border-radius: 6px; + padding: 3px 8px; + min-width: 36px; + text-align: center; + flex-shrink: 0; +} + +.mobile-departure-dir { + flex: 1; + font-size: 13px; + color: var(--text); +} + +.mobile-departure-time { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + font-weight: 500; + color: var(--green); + flex-shrink: 0; +} + +.mobile-departure-time.delayed { color: var(--yellow); } diff --git a/src/pages/MobileHome.tsx b/src/pages/MobileHome.tsx new file mode 100644 index 0000000..26d8156 --- /dev/null +++ b/src/pages/MobileHome.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react' + +/* ── Types ── */ +interface HourlySlot { hour: number; tempC: number; code: number; desc: string } +interface WeatherData { + tempC: number; feelsLikeC: number; humidity: number; windKmph: number + desc: string; code: number; city: string + todayMin: number; todayMax: number; hourly: HourlySlot[] +} +interface Departure { + line: string; direction: string + scheduledTime: string; realtimeTime: string + realtime: boolean; product: string; platform: string +} +interface TransitData { stop: string; notices: string[]; departures: Departure[] } + +/* ── Helpers ── */ +function weatherEmoji(code: number): string { + if (code === 113) return '☀️' + if (code === 116) return '⛅' + if (code === 119 || code === 122) return '☁️' + if ([143, 248, 260].includes(code)) return '🌫️' + if ([176, 263, 266, 281, 284, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return '🌧️' + if ([179, 182, 185, 227, 230, 317, 320, 323, 326, 329, 332, 335, 338, 350, 362, 365, 368, 371, 374, 377].includes(code)) return '❄️' + if ([200, 386, 389, 392, 395].includes(code)) return '⛈️' + return '🌡️' +} + +function minutesUntil(iso: string): number { + return Math.round((new Date(iso).getTime() - Date.now()) / 60000) +} + +function formatMinutes(min: number): string { + if (min <= 0) return 'now' + if (min === 1) return '1 min' + return `${min} min` +} + +function productColor(product: string): string { + if (product === 'UBahn') return '#2563eb' + if (product === 'Bus') return '#16a34a' + return '#d97706' // Tram +} + +/* ── Weather card ── */ +function MobileWeather() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/weather/current').then(r => r.json()).then(d => { + if (d.error) setError(d.error); else setData(d) + }).catch(() => setError('Failed to fetch weather')) + }, []) + + if (error) return

⚠ {error}

+ if (!data) return
Loading weather…
+ + const now = new Date().getHours() + const upcoming = data.hourly.filter(h => h.hour >= now) + + return ( +
+ {/* Main temp */} +
+ {weatherEmoji(data.code)} +
+
{data.tempC}°C
+
{data.desc}
+
+
+ ↑{data.todayMax}° + ↓{data.todayMin}° +
+
+ + {/* Stats row */} +
+ Feels {data.feelsLikeC}°C + · + {data.humidity}% humidity + · + {data.windKmph} km/h wind +
+ + {/* Hourly scroll */} +
+ {upcoming.map(h => ( +
+
{h.hour === now ? 'Now' : `${String(h.hour).padStart(2, '0')}:00`}
+
{weatherEmoji(h.code)}
+
{h.tempC}°
+
+ ))} +
+
+ ) +} + +/* ── Transit card ── */ +function MobileTransit() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [updated, setUpdated] = useState(new Date()) + + const load = () => { + fetch('/api/transit/departures').then(r => r.json()).then(d => { + if (d.error) setError(d.error); else { setData(d); setUpdated(new Date()) } + }).catch(() => setError('Failed to fetch departures')) + } + + useEffect(() => { + load() + const id = setInterval(load, 60000) + return () => clearInterval(id) + }, []) + + if (error) return

⚠ {error}

+ if (!data) return
Loading departures…
+ + return ( +
+
+
+
{data.stop}
+
+ Updated {updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} +
+
+ +
+ + {data.notices.length > 0 && ( +
+ {data.notices.map((n, i) => ( +
⚠ {n}
+ ))} +
+ )} + +
+ {data.departures.map((d, i) => { + const min = minutesUntil(d.realtimeTime) + const delayed = d.realtime && new Date(d.realtimeTime) > new Date(d.scheduledTime) + if (min < -1) return null + return ( +
+
+ {d.line} +
+
{d.direction}
+
+ {formatMinutes(min)} +
+
+ ) + })} +
+
+ ) +} + +/* ── Page ── */ +export function MobileHome() { + return ( +
+ + +
+ ) +}