From 03fee7dca9d7bf08b5a9d1b22d11b184947880d3 Mon Sep 17 00:00:00 2001 From: cediackermann Date: Tue, 21 Apr 2026 09:36:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20make=20all=20widgets=20fully=20responsi?= =?UTF-8?q?ve=20via=20ResizeObserver=20=E2=80=94=20scale=20content=20to=20?= =?UTF-8?q?slot=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/atoms/LineBadge.tsx | 21 ++- src/components/atoms/SBBLineBadge.tsx | 20 +- src/components/molecules/Clock.tsx | 48 ++++- src/components/molecules/DepartureItem.tsx | 30 ++- src/components/molecules/SBBDepartureItem.tsx | 45 ++++- src/components/organisms/DepartureList.tsx | 58 +++++- src/components/organisms/SBBDepartureList.tsx | 60 +++++- src/components/organisms/SpotifyWidget.tsx | 67 +++++-- src/components/organisms/SystemWidget.tsx | 87 ++++++--- src/components/organisms/WeatherWidget.tsx | 176 +++++++----------- src/hooks/useSize.ts | 19 ++ src/pages/Dashboard.tsx | 111 +++++------ 12 files changed, 498 insertions(+), 244 deletions(-) create mode 100644 src/hooks/useSize.ts diff --git a/src/components/atoms/LineBadge.tsx b/src/components/atoms/LineBadge.tsx index 6a3a1f7..35ec678 100644 --- a/src/components/atoms/LineBadge.tsx +++ b/src/components/atoms/LineBadge.tsx @@ -1,5 +1,22 @@ -export const LineBadge = ({ designation }: { designation: string; mode?: string; groupOfLines?: string }) => ( - +/** SL line badge. `size` is the pixel height of the badge. */ +export const LineBadge = ({ + designation, + size = 36, +}: { + designation: string; + mode?: string; + groupOfLines?: string; + size?: number; +}) => ( + {designation} ); diff --git a/src/components/atoms/SBBLineBadge.tsx b/src/components/atoms/SBBLineBadge.tsx index 617d091..47a2ae2 100644 --- a/src/components/atoms/SBBLineBadge.tsx +++ b/src/components/atoms/SBBLineBadge.tsx @@ -1,5 +1,21 @@ -export const SBBLineBadge = ({ name }: { name: string; category?: string }) => ( - +/** SBB line badge. `size` is the pixel height of the badge. */ +export const SBBLineBadge = ({ + name, + size = 36, +}: { + name: string; + category?: string; + size?: number; +}) => ( + {name} ); diff --git a/src/components/molecules/Clock.tsx b/src/components/molecules/Clock.tsx index ed056f5..bbe04de 100644 --- a/src/components/molecules/Clock.tsx +++ b/src/components/molecules/Clock.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; +import { useSize } from '../../hooks/useSize'; export const Clock = () => { const alwaysOn = useAlwaysOn(); + const { w, h, ref } = useSize(); const [time, setTime] = useState(new Date()); useEffect(() => { @@ -10,8 +12,7 @@ export const Clock = () => { const id = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(id); } - - // Sync to the next full minute, then tick every 60 s + // In alwaysOn mode: sync to the next full minute, then tick every 60 s let interval: ReturnType; const now = new Date(); const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); @@ -19,22 +20,49 @@ export const Clock = () => { setTime(new Date()); interval = setInterval(() => setTime(new Date()), 60_000); }, msUntilNext); - return () => { clearTimeout(timeout); clearInterval(interval); }; }, [alwaysOn]); - const timeStr = alwaysOn - ? time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false }) - : time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + const effectiveH = h || 80; + const effectiveW = w || 400; + const showSeconds = !alwaysOn && effectiveH > 100; + const showDate = effectiveH > 55 || effectiveW > 280; + const longDate = effectiveH > 130; + + const timeStr = time.toLocaleTimeString('en-GB', { + hour: '2-digit', minute: '2-digit', + ...(showSeconds ? { second: '2-digit' } : {}), + hour12: false, + }); const dateStr = time.toLocaleDateString('en-GB', { - weekday: 'long', day: 'numeric', month: 'long', + weekday: longDate ? 'long' : 'short', + day: 'numeric', + month: longDate ? 'long' : 'short', + ...(effectiveH > 200 ? { year: 'numeric' } : {}), }); + // Fluid font size: fill width or height, whichever is tighter. + // Each character is ~0.60 em wide in tabular-nums. + const charCount = timeStr.length; + const fontByW = (effectiveW * 0.90) / (charCount * 0.60); + const fontByH = showDate ? effectiveH * 0.50 : effectiveH * 0.72; + const fontSize = Math.max(16, Math.min(fontByW, fontByH, 220)); + const dateFontSize = Math.max(12, Math.min(fontSize * 0.22, 32)); + return ( -
-
{timeStr}
-
{dateStr}
+
+
+ {timeStr} +
+ {showDate && ( +
+ {dateStr} +
+ )}
); }; diff --git a/src/components/molecules/DepartureItem.tsx b/src/components/molecules/DepartureItem.tsx index c778254..1f43d94 100644 --- a/src/components/molecules/DepartureItem.tsx +++ b/src/components/molecules/DepartureItem.tsx @@ -2,19 +2,33 @@ import { LineBadge } from '../atoms/LineBadge'; import { Departure } from '../../types'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; -export const DepartureItem = ({ departure }: { departure: Departure }) => { +export const DepartureItem = ({ + departure, + rowHeight = 48, +}: { + departure: Departure; + rowHeight?: number; +}) => { const alwaysOn = useAlwaysOn(); - const depTime = new Date(departure.scheduled); - const diffMin = Math.round((depTime.getTime() - Date.now()) / 60000); + const depTime = new Date(departure.scheduled); + const diffMin = Math.round((depTime.getTime() - Date.now()) / 60000); const timeText = diffMin <= 0 ? 'Now' : `${diffMin} min`; return ( -
-
- - {departure.destination} +
+
+ + {departure.destination}
- {timeText} + {timeText}
); }; diff --git a/src/components/molecules/SBBDepartureItem.tsx b/src/components/molecules/SBBDepartureItem.tsx index d09d1b2..ea2fa5d 100644 --- a/src/components/molecules/SBBDepartureItem.tsx +++ b/src/components/molecules/SBBDepartureItem.tsx @@ -2,21 +2,46 @@ import { SBBLineBadge } from '../atoms/SBBLineBadge'; import { SBBDeparture } from '../../types'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; -export const SBBDepartureItem = ({ departure }: { departure: SBBDeparture }) => { +export const SBBDepartureItem = ({ + departure, + rowHeight = 48, + showPlatform = false, +}: { + departure: SBBDeparture; + rowHeight?: number; + showPlatform?: boolean; +}) => { const alwaysOn = useAlwaysOn(); - const dep = departure.stop.departure ? new Date(departure.stop.departure) : null; - const diffMin = dep ? Math.round((dep.getTime() - Date.now()) / 60000) : null; + const dep = departure.stop.departure ? new Date(departure.stop.departure) : null; + const diffMin = dep ? Math.round((dep.getTime() - Date.now()) / 60000) : null; const timeText = diffMin === null ? '—' : diffMin <= 0 ? 'Now' : `${diffMin} min`; - const delay = departure.stop.delay; + const delay = departure.stop.delay; + const fontSize = Math.max(11, rowHeight * 0.40); return ( -
-
- - {departure.to} +
+
+ + {departure.to}
-
- {delay != null && delay > 0 && +{delay}'} +
+ {showPlatform && departure.stop.platform && ( + + Pl.{departure.stop.platform} + + )} + {delay != null && delay > 0 && ( + + +{delay}' + + )} {timeText}
diff --git a/src/components/organisms/DepartureList.tsx b/src/components/organisms/DepartureList.tsx index faa2ebe..5f9e7c8 100644 --- a/src/components/organisms/DepartureList.tsx +++ b/src/components/organisms/DepartureList.tsx @@ -1,14 +1,58 @@ +import { useSize } from '../../hooks/useSize'; import { DepartureItem } from '../molecules/DepartureItem'; import { Departure } from '../../types'; -export const DepartureList = ({ departures, loading }: { departures: Departure[]; loading: boolean }) => { - if (loading && departures.length === 0) return

Loading...

; - if (!loading && departures.length === 0) return

No departures.

; +export const DepartureList = ({ + departures, + loading, + stationName, +}: { + departures: Departure[]; + loading: boolean; + stationName?: string; +}) => { + const { h, w, ref } = useSize(); + const effectiveH = h || 200; + + // Header sizing + const headerH = stationName ? Math.max(24, Math.min(effectiveH * 0.17, 44)) : 0; + const headerFontSz = Math.max(11, headerH * 0.55); + + // Row sizing: taller containers get bigger rows + const listH = effectiveH - headerH - (headerH ? 6 : 0); + const rowH = listH > 500 ? 68 : listH > 360 ? 54 : listH > 240 ? 42 : listH > 150 ? 34 : 27; + const maxRows = Math.max(1, Math.floor(listH / rowH)); + const visible = departures.slice(0, maxRows); + + if (loading && departures.length === 0) { + return ( +
+ Loading… +
+ ); + } + return ( -
- {departures.map((dep, i) => ( - - ))} +
+ {stationName && ( +
+ {stationName} +
+ )} + {visible.length === 0 ? ( + No departures + ) : ( + visible.map((dep, i) => ( + + )) + )}
); }; diff --git a/src/components/organisms/SBBDepartureList.tsx b/src/components/organisms/SBBDepartureList.tsx index c8a5a5a..6df3330 100644 --- a/src/components/organisms/SBBDepartureList.tsx +++ b/src/components/organisms/SBBDepartureList.tsx @@ -1,14 +1,60 @@ +import { useSize } from '../../hooks/useSize'; import { SBBDepartureItem } from '../molecules/SBBDepartureItem'; import { SBBDeparture } from '../../types'; -export const SBBDepartureList = ({ departures, loading }: { departures: SBBDeparture[]; loading: boolean }) => { - if (loading && departures.length === 0) return

Loading...

; - if (!loading && departures.length === 0) return

No departures.

; +export const SBBDepartureList = ({ + departures, + loading, + stationName, +}: { + departures: SBBDeparture[]; + loading: boolean; + stationName?: string; +}) => { + const { h, w, ref } = useSize(); + const effectiveH = h || 200; + + // Header sizing + const headerH = stationName ? Math.max(24, Math.min(effectiveH * 0.17, 44)) : 0; + const headerFontSz = Math.max(11, headerH * 0.55); + + // Row sizing: taller containers get bigger rows and platform info + const listH = effectiveH - headerH - (headerH ? 6 : 0); + const rowH = listH > 500 ? 68 : listH > 360 ? 54 : listH > 240 ? 42 : listH > 150 ? 34 : 27; + const maxRows = Math.max(1, Math.floor(listH / rowH)); + const showPlatform = rowH >= 54 && (w || 0) > 300; + const visible = departures.slice(0, maxRows); + + if (loading && departures.length === 0) { + return ( +
+ Loading… +
+ ); + } + return ( -
- {departures.map((dep, i) => ( - - ))} +
+ {stationName && ( +
+ {stationName} +
+ )} + {visible.length === 0 ? ( + No departures + ) : ( + visible.map((dep, i) => ( + + )) + )}
); }; diff --git a/src/components/organisms/SpotifyWidget.tsx b/src/components/organisms/SpotifyWidget.tsx index a2d1736..1d109c3 100644 --- a/src/components/organisms/SpotifyWidget.tsx +++ b/src/components/organisms/SpotifyWidget.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; +import { useSize } from '../../hooks/useSize'; import { SpotifyNowPlaying } from '../../types'; function fmtMs(ms: number) { @@ -9,36 +10,78 @@ function fmtMs(ms: number) { export const SpotifyWidget = () => { const alwaysOn = useAlwaysOn(); - + const { w, h, ref } = useSize(); const { data } = useQuery({ queryKey: ['spotify-now-playing'], queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()), refetchInterval: alwaysOn ? 30_000 : 1_000, }); + const effectiveH = h || 100; + const effectiveW = w || 300; + + // Progressive disclosure + const showLabel = effectiveH > 80; + const showArtist = effectiveH > 65 || effectiveW > 240; + const showAlbum = effectiveH > 120 && effectiveW > 260; + const showProgress = !alwaysOn && effectiveH > 95 && effectiveW > 200; + const showProgressTime = showProgress && effectiveW > 280; + + // Font sizes derived from container + const trackFS = Math.min(effectiveH * 0.28, effectiveW * 0.10, 40); + const artistFS = Math.max(11, trackFS * 0.70); + const albumFS = Math.max(10, trackFS * 0.55); + const labelFS = Math.max(9, trackFS * 0.45); + const barH = Math.max(2, effectiveH * 0.018); + return ( -
-

Now Playing

+
+ {showLabel && ( +
+ Now Playing +
+ )} {data?.playing && data.track ? ( <> -

{data.track.name}

-

{data.track.artist}

-

{data.track.album}

- {!alwaysOn && ( -
- {fmtMs(data.track.progressMs)} -
+
+ {data.track.name} +
+ {showArtist && ( +
+ {data.track.artist} +
+ )} + {showAlbum && ( +
+ {data.track.album} +
+ )} + {showProgress && ( +
+ {showProgressTime && ( + + {fmtMs(data.track.progressMs)} + + )} +
- {fmtMs(data.track.durationMs)} + {showProgressTime && ( + + {fmtMs(data.track.durationMs)} + + )}
)} ) : ( -

Nothing playing

+
Nothing playing
)}
); diff --git a/src/components/organisms/SystemWidget.tsx b/src/components/organisms/SystemWidget.tsx index 036bd7e..41cd446 100644 --- a/src/components/organisms/SystemWidget.tsx +++ b/src/components/organisms/SystemWidget.tsx @@ -1,48 +1,91 @@ import { useQuery } from '@tanstack/react-query'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; +import { useSize } from '../../hooks/useSize'; interface SystemData { - cpu: { percent: number; load: string[] }; + cpu: { percent: number; load: string[] }; memory: { percent: number; usedGb: string; totalGb: string }; - disk: { percent: number; usedGb: string; totalGb: string }; + disk: { percent: number; usedGb: string; totalGb: string }; uptime: string; hostname: string; } -const Bar = ({ percent }: { percent: number }) => ( -
-
-
-); - -const Row = ({ label, percent, detail }: { label: string; percent: number; detail: string }) => ( -
- {label} - - {percent}% +const MetricRow = ({ + label, percent, detail, rowH, barH, fontSize, +}: { + label: string; percent: number; detail: string; + rowH: number; barH: number; fontSize: number; +}) => ( +
+ {label} +
+
+
{detail}
); export const SystemWidget = () => { const alwaysOn = useAlwaysOn(); + const { w, h, ref } = useSize(); const { data } = useQuery({ queryKey: ['system'], queryFn: () => fetch('/api/system').then(r => r.json()), refetchInterval: alwaysOn ? 60_000 : 2_000, }); - if (!data) return null; + if (!data) return
; + + const effectiveH = h || 120; + const effectiveW = w || 300; + + // Progressive disclosure + const showHostname = effectiveH > 55; + const showDisk = effectiveH > 95; + const showUptime = showHostname && (effectiveH > 120 || effectiveW > 450); + const showLoadDetail = effectiveH > 150 && effectiveW > 380; + + // Row sizing + const metricCount = 2 + (showDisk ? 1 : 0); + const hostH = showHostname ? Math.min(effectiveH * 0.22, 32) : 0; + const availH = effectiveH - hostH - (metricCount - 1) * 5; + const rowH = Math.max(14, Math.min(availH / metricCount, 52)); + const fontSize = Math.max(10, rowH * 0.52); + const barH = Math.max(2, rowH * 0.22); + const hostFontSz = Math.max(12, Math.min(effectiveH * 0.14, 28)); return ( -
-
- {data.hostname} - up {data.uptime} -
- - - +
+ {showHostname && ( +
+ {data.hostname} + {showUptime && ( + + up {data.uptime} + + )} +
+ )} + + + {showDisk && ( + + )}
); }; diff --git a/src/components/organisms/WeatherWidget.tsx b/src/components/organisms/WeatherWidget.tsx index a59177b..4fc3fd2 100644 --- a/src/components/organisms/WeatherWidget.tsx +++ b/src/components/organisms/WeatherWidget.tsx @@ -1,135 +1,103 @@ import { useQuery } from '@tanstack/react-query'; +import { useSize } from '../../hooks/useSize'; import { WeatherData } from '../../types'; -// 5-line ASCII art keyed by condition keyword const ASCII: Record = { - sunny: [ - ' \\ / ', - ' .-. ', - ' ― ( ) ―', - ' `-\' ', - ' / \\ ', - ], - partlycloudy: [ - ' \\ / ', - ' _ /"".--. ', - ' \\_( { ) ', - ' `-\'--\' ', - ' ', - ], - cloudy: [ - ' ', - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - ' ', - ], - overcast: [ - ' ', - ' .------. ', - '.-( ).', - '(__________.) ', - ' ', - ], - rain: [ - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - " ʻ ʻ ʻ ʻ ", - " ʻ ʻ ʻ ʻ ", - ], - drizzle: [ - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - " , , , , ", - " ", - ], - snow: [ - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - ' * * * ', - '* * * * ', - ], - thunder: [ - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - ' / \\ ', - ' / \\ ', - ], - fog: [ - ' ', - ' _ - _ - _ -', - ' _ - _ - _ ', - ' _ - _ - _ -', - ' ', - ], - sleet: [ - ' .--. ', - ' .-( ). ', - '(___.__)__) ', - " * ʻ * ʻ ", - " ʻ * ʻ * ", - ], + sunny: [' \\ / ', ' .-. ', ' ― ( ) ―', ' `-\' ', ' / \\ '], + partlycloudy: [' \\ / ', ' _ /"".--. ', ' \\_( { ) ', ' `-\'--\' ', ' '], + cloudy: [' ', ' .--. ', ' .-( ). ', '(___.__)__) ', ' '], + overcast: [' ', ' .------. ', '.-( ).', '(__________.) ', ' '], + rain: [' .--. ', ' .-( ). ', '(___.__)__) ', " ʻ ʻ ʻ ʻ ", " ʻ ʻ ʻ ʻ "], + drizzle: [' .--. ', ' .-( ). ', '(___.__)__) ', " , , , , ", " "], + snow: [' .--. ', ' .-( ). ', '(___.__)__) ', ' * * * ', '* * * * '], + thunder: [' .--. ', ' .-( ). ', '(___.__)__) ', ' / \\ ', ' / \\ '], + fog: [' ', ' _ - _ - _ -', ' _ - _ - _ ', ' _ - _ - _ -', ' '], + sleet: [' .--. ', ' .-( ). ', '(___.__)__) ', " * ʻ * ʻ ", " ʻ * ʻ * "], }; const MOON_CHAR: Record = { - 'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓', - 'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖', - 'Last Quarter': '🌗', 'Waning Crescent': '🌘', + 'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓', 'Waxing Gibbous': '🌔', + 'Full Moon': '🌕', 'Waning Gibbous': '🌖', 'Last Quarter': '🌗', 'Waning Crescent': '🌘', }; function getAsciiKey(desc: string): string { const d = desc.toLowerCase(); - if (d.includes('thunder')) return 'thunder'; - if (d.includes('snow')) return 'snow'; - if (d.includes('sleet')) return 'sleet'; - if (d.includes('drizzle')) return 'drizzle'; - if (d.includes('rain')) return 'rain'; - if (d.includes('fog') || d.includes('mist') || d.includes('haze')) return 'fog'; - if (d.includes('overcast')) return 'overcast'; - if (d.includes('mostly cloudy') || d.includes('very cloudy')) return 'cloudy'; - if (d.includes('partly cloudy') || d.includes('mostly sunny')) return 'partlycloudy'; - if (d.includes('cloudy')) return 'cloudy'; - if (d.includes('sunny') || d.includes('clear')) return 'sunny'; + if (d.includes('thunder')) return 'thunder'; + if (d.includes('snow')) return 'snow'; + if (d.includes('sleet')) return 'sleet'; + if (d.includes('drizzle')) return 'drizzle'; + if (d.includes('rain')) return 'rain'; + if (d.includes('fog') || d.includes('mist')) return 'fog'; + if (d.includes('overcast')) return 'overcast'; + if (d.includes('mostly cloudy') || d.includes('very')) return 'cloudy'; + if (d.includes('partly') || d.includes('mostly sunny')) return 'partlycloudy'; + if (d.includes('cloudy')) return 'cloudy'; + if (d.includes('sunny') || d.includes('clear')) return 'sunny'; return 'cloudy'; } export const WeatherWidget = () => { + const { w, h, ref } = useSize(); const { data, isError } = useQuery({ queryKey: ['weather'], queryFn: () => fetch('/api/weather').then(r => r.json()), refetchInterval: 10 * 60 * 1000, }); + const effectiveH = h || 100; + const effectiveW = w || 350; + if (isError || !data || (data as any).error) { - return
Weather unavailable
; + return ( +
+ Weather unavailable +
+ ); } - const art = ASCII[getAsciiKey(data.description)] ?? ASCII.cloudy; + // Progressive disclosure based on available space + const showAscii = effectiveH > 200 && effectiveW > 380; + const showHourly = effectiveH > 140 && effectiveW > 260; + const showSunMoon = effectiveH > 95 || effectiveW > 460; + + // Font sizes derived from container + const tempFontSize = Math.min(effectiveH * (showAscii ? 0.34 : 0.42), 84); + const descFontSize = Math.max(12, tempFontSize * 0.36); + const metaFontSize = Math.max(10, tempFontSize * 0.27); + const asciiFontSize = Math.max(9, effectiveH / 8); + const maxHours = effectiveW > 500 ? 6 : effectiveW > 380 ? 4 : 3; + + const art = ASCII[getAsciiKey(data.description)] ?? ASCII.cloudy; const moon = MOON_CHAR[data.moonPhase] ?? data.moonPhase; return ( -
-
{art.join('\n')}
-
-
- {data.temperature}°C - {data.description} +
+ {showAscii && ( +
+          {art.join('\n')}
+        
+ )} +
+
+ {data.temperature}°C + {data.description}
-
- ↑ {data.sunrise} - ↓ {data.sunset} - {moon} {data.moonPhase} -
- {data.hourly.length > 0 && ( -
- {data.hourly.map((h, i) => ( -
-
{h.time}
-
{h.temp}°
+ {showSunMoon && ( +
+ ↑ {data.sunrise} + ↓ {data.sunset} + {moon} {data.moonPhase} +
+ )} + {showHourly && data.hourly.length > 0 && ( +
+ {data.hourly.slice(0, maxHours).map((entry, i) => ( +
+
{entry.time}
+
{entry.temp}°
))}
diff --git a/src/hooks/useSize.ts b/src/hooks/useSize.ts new file mode 100644 index 0000000..4c81659 --- /dev/null +++ b/src/hooks/useSize.ts @@ -0,0 +1,19 @@ +import { useCallback, useRef, useState } from 'react'; + +/** Measures a div's content box via ResizeObserver. Attach `ref` to the element. */ +export function useSize() { + const [size, setSize] = useState({ w: 0, h: 0 }); + const roRef = useRef(null); + + const ref = useCallback((el: HTMLDivElement | null) => { + roRef.current?.disconnect(); + if (!el) return; + roRef.current = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + setSize({ w: Math.round(width), h: Math.round(height) }); + }); + roRef.current.observe(el); + }, []); + + return { ...size, ref }; +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 440fe22..8d2034a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -11,8 +11,10 @@ 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']; +const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3']; +const DEPARTURE_IDS: WidgetId[] = ['sl', 'sbb']; + +// ── Dashboard ───────────────────────────────────────────────────────────────── export const Dashboard = () => { const { data: config } = useQuery({ @@ -21,121 +23,110 @@ export const Dashboard = () => { }); const alwaysOn = !!config?.alwaysOn; - const layout = config?.layout ?? {}; - + const layout = config?.layout ?? {}; const inLayout = (id: WidgetId) => Object.values(layout).includes(id); + // In alwaysOn mode, tick once per minute to keep departure times current 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); }; + let interval: ReturnType; + const now = new Date(); + const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + const timeout = setTimeout(() => { tick(); interval = setInterval(tick, 60_000); }, msUntilNext); + return () => { clearTimeout(timeout); clearInterval(interval); }; }, [alwaysOn]); - const { data: slData, isLoading: slLoading } = useQuery<{ departures: Departure[] }>({ + // Data fetching — only enabled when the widget is actually in the layout + 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, + 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, + queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`), + enabled: inLayout('sbb') && !!config?.sbb?.stationId, refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), }); + const slName = slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'; + const sbbName = config?.sbb?.stationName || 'SBB'; + + // ── Widget factory ─────────────────────────────────────────────────────────── + const renderWidget = (id: WidgetId | undefined) => { if (!id || id === 'spacer') return null; switch (id) { - case 'clock': return
; - case 'weather': return
; + 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'} -

- -
- ); + case 'sl': return ; + case 'sbb': return ; } }; + // ── Layout derivations ─────────────────────────────────────────────────────── + 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 footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter((w): w is WidgetId => w != null); - 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 hasHeader = !!(headerLeft || headerRight); + const hasMain = !!(main1 && main1 !== 'spacer') || !!(main2 && main2 !== 'spacer'); + const hasFooter = footerWidgets.some(w => w !== 'spacer'); + const footerHasDeps = footerWidgets.some(w => DEPARTURE_IDS.includes(w)); - const footerHasDeps = footerWidgets.some(w => DEPARTURE_WIDGETS.includes(w)); + // Zone class: fills its grid/flex cell and prevents content from overflowing + const zone = 'h-full min-h-0 min-w-0 overflow-hidden'; - // 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. + // Footer height: departure lists need more room; info-only widgets get 20 vh const footerStyle: React.CSSProperties = !hasMain ? { flex: '1 1 0', minHeight: 0 } : footerHasDeps ? { flex: '0 0 40%', minHeight: 0 } - : { flex: '0 0 auto', minHeight: '140px' }; + : { flex: '0 0 20%', minHeight: '120px' }; - // Zone wrapper: min-h-0 prevents a child from inflating beyond the slot - const zoneClass = 'min-h-0 min-w-0 overflow-hidden'; + // ── Render ─────────────────────────────────────────────────────────────────── return (
{hasHeader && ( -
-
{renderWidget(headerLeft)}
- {headerRight &&
{renderWidget(headerRight)}
} +
+
{renderWidget(headerLeft)}
+ {headerRight && ( +
{renderWidget(headerRight)}
+ )}
)} {hasMain && (
- {main1 &&
{renderWidget(main1)}
} - {main2 &&
{renderWidget(main2)}
} + {main1 &&
{renderWidget(main1)}
} + {main2 &&
{renderWidget(main2)}
}
)} {hasFooter && (
{footerWidgets.map((w, i) => ( -
{renderWidget(w)}
+
{renderWidget(w)}
))}
)}