import { useEffect, useReducer } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Clock } from '../components/molecules/Clock'; import { DepartureList } from '../components/organisms/DepartureList'; import { SBBDepartureList } from '../components/organisms/SBBDepartureList'; import { SpotifyWidget } from '../components/organisms/SpotifyWidget'; import { WeatherWidget } from '../components/organisms/WeatherWidget'; import { SystemWidget } from '../components/organisms/SystemWidget'; import { AlwaysOnContext } from '../contexts/AlwaysOnContext'; import { Config, Departure, SBBDeparture, WidgetId, ZoneId } from '../types'; const fetchJson = (url: string) => fetch(url).then(r => r.json()); const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3']; const DEPARTURE_WIDGETS: WidgetId[] = ['sl', 'sbb']; export const Dashboard = () => { const { data: config } = useQuery({ queryKey: ['config'], queryFn: () => fetchJson('/api/config'), }); const alwaysOn = !!config?.alwaysOn; const layout = config?.layout ?? {}; const inLayout = (id: WidgetId) => Object.values(layout).includes(id); const [, tick] = useReducer(x => x + 1, 0); useEffect(() => { if (!alwaysOn) return; let id: ReturnType; const schedule = () => { const now = new Date(); const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); return setTimeout(() => { tick(); id = setInterval(tick, 60_000); }, msUntilNext); }; const timeout = schedule(); return () => { clearTimeout(timeout); clearInterval(id); }; }, [alwaysOn]); const { data: slData, isLoading: slLoading } = useQuery<{ departures: Departure[] }>({ queryKey: ['sl-departures', config?.sl?.stationId], queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`), enabled: inLayout('sl') && !!config?.sl?.stationId, refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), }); const { data: sbbData, isLoading: sbbLoading } = useQuery<{ stationboard: SBBDeparture[] }>({ queryKey: ['sbb-departures', config?.sbb?.stationId], queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`), enabled: inLayout('sbb') && !!config?.sbb?.stationId, refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), }); const renderWidget = (id: WidgetId | undefined) => { if (!id || id === 'spacer') return null; switch (id) { case 'clock': return
; case 'weather': return
; case 'spotify': return ; case 'system': return ; case 'sl': return (

{slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'}

); case 'sbb': return (

{config?.sbb?.stationName || 'SBB'}

); } }; const headerLeft = layout['header-left']; const headerRight = layout['header-right']; const main1 = layout['main-1']; const main2 = layout['main-2']; // Keep spacers — they hold intentional empty slots, only collapse truly unset zones const footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter(w => w != null) as WidgetId[]; const hasHeader = headerLeft || headerRight; // spacer alone doesn't count as real content const hasMain = (main1 && main1 !== 'spacer') || (main2 && main2 !== 'spacer'); const hasFooter = footerWidgets.filter(w => w !== 'spacer').length > 0; const footerHasDeps = footerWidgets.some(w => DEPARTURE_WIDGETS.includes(w)); // Footer sizing: takes 40% of viewport when holding departure lists so rows are visible. // When only info widgets: auto-height. When no main: expands to fill. const footerStyle: React.CSSProperties = !hasMain ? { flex: '1 1 0', minHeight: 0 } : footerHasDeps ? { flex: '0 0 40%', minHeight: 0 } : { flex: '0 0 auto', minHeight: '140px' }; // Zone wrapper: min-h-0 prevents a child from inflating beyond the slot const zoneClass = 'min-h-0 min-w-0 overflow-hidden'; return (
{hasHeader && (
{renderWidget(headerLeft)}
{headerRight &&
{renderWidget(headerRight)}
}
)} {hasMain && (
{main1 &&
{renderWidget(main1)}
} {main2 &&
{renderWidget(main2)}
}
)} {hasFooter && (
{footerWidgets.map((w, i) => (
{renderWidget(w)}
))}
)}
); };