walldash/src/pages/Dashboard.tsx

146 lines
6 KiB
TypeScript

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<Config>({
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<typeof setInterval>;
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 <div className="h-full flex items-center"><Clock /></div>;
case 'weather': return <div className="h-full flex items-center"><WeatherWidget /></div>;
case 'spotify': return <SpotifyWidget />;
case 'system': return <SystemWidget />;
case 'sl':
return (
<div className="h-full flex flex-col">
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
{slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'}
</h2>
<DepartureList departures={slData?.departures ?? []} loading={slLoading} />
</div>
);
case 'sbb':
return (
<div className="h-full flex flex-col">
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
{config?.sbb?.stationName || 'SBB'}
</h2>
<SBBDepartureList departures={sbbData?.stationboard ?? []} loading={sbbLoading} />
</div>
);
}
};
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 (
<AlwaysOnContext.Provider value={alwaysOn}>
<div className="flex flex-col h-screen w-screen p-6 gap-4 overflow-hidden">
{hasHeader && (
<div className="flex items-center justify-between border-b border-white pb-4 shrink-0">
<div className={`flex-1 ${zoneClass}`}>{renderWidget(headerLeft)}</div>
{headerRight && <div className={`flex-shrink-0 ml-8 ${zoneClass}`}>{renderWidget(headerRight)}</div>}
</div>
)}
{hasMain && (
<div
className={`${main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}`}
style={{ flex: '1 1 0', minHeight: 0 }}
>
{main1 && <div className={`${main1 && main2 ? '' : 'flex-1'} ${zoneClass}`}>{renderWidget(main1)}</div>}
{main2 && <div className={zoneClass}>{renderWidget(main2)}</div>}
</div>
)}
{hasFooter && (
<div
className="border-t border-white pt-4 grid gap-6"
style={{
...footerStyle,
gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)`,
}}
>
{footerWidgets.map((w, i) => (
<div key={`${w}-${i}`} className={`${zoneClass} h-full`}>{renderWidget(w)}</div>
))}
</div>
)}
</div>
</AlwaysOnContext.Provider>
);
};