feat: make all widgets fully responsive via ResizeObserver — scale content to slot size

This commit is contained in:
cediackermann 2026-04-21 09:36:12 +02:00
parent fbfec5a323
commit 03fee7dca9
12 changed files with 498 additions and 244 deletions

View file

@ -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. */
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block"> export const LineBadge = ({
designation,
size = 36,
}: {
designation: string;
mode?: string;
groupOfLines?: string;
size?: number;
}) => (
<span
className="border border-white text-white font-bold text-center tabular-nums shrink-0 inline-flex items-center justify-center"
style={{
height: size,
minWidth: size * 1.6,
fontSize: Math.max(10, size * 0.44),
padding: `0 ${size * 0.18}px`,
}}
>
{designation} {designation}
</span> </span>
); );

View file

@ -1,5 +1,21 @@
export const SBBLineBadge = ({ name }: { name: string; category?: string }) => ( /** SBB line badge. `size` is the pixel height of the badge. */
<span className="border border-white text-white px-2 py-0.5 font-bold min-w-[56px] text-center text-xl inline-block"> export const SBBLineBadge = ({
name,
size = 36,
}: {
name: string;
category?: string;
size?: number;
}) => (
<span
className="border border-white text-white font-bold text-center tabular-nums shrink-0 inline-flex items-center justify-center"
style={{
height: size,
minWidth: size * 1.6,
fontSize: Math.max(10, size * 0.44),
padding: `0 ${size * 0.18}px`,
}}
>
{name} {name}
</span> </span>
); );

View file

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
import { useSize } from '../../hooks/useSize';
export const Clock = () => { export const Clock = () => {
const alwaysOn = useAlwaysOn(); const alwaysOn = useAlwaysOn();
const { w, h, ref } = useSize();
const [time, setTime] = useState(new Date()); const [time, setTime] = useState(new Date());
useEffect(() => { useEffect(() => {
@ -10,8 +12,7 @@ export const Clock = () => {
const id = setInterval(() => setTime(new Date()), 1000); const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id); return () => clearInterval(id);
} }
// In alwaysOn mode: sync to the next full minute, then tick every 60 s
// Sync to the next full minute, then tick every 60 s
let interval: ReturnType<typeof setInterval>; let interval: ReturnType<typeof setInterval>;
const now = new Date(); const now = new Date();
const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
@ -19,22 +20,49 @@ export const Clock = () => {
setTime(new Date()); setTime(new Date());
interval = setInterval(() => setTime(new Date()), 60_000); interval = setInterval(() => setTime(new Date()), 60_000);
}, msUntilNext); }, msUntilNext);
return () => { clearTimeout(timeout); clearInterval(interval); }; return () => { clearTimeout(timeout); clearInterval(interval); };
}, [alwaysOn]); }, [alwaysOn]);
const timeStr = alwaysOn const effectiveH = h || 80;
? time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false }) const effectiveW = w || 400;
: time.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
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', { 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 ( return (
<div className="flex items-baseline gap-6"> <div ref={ref} className="w-full h-full flex flex-col justify-center overflow-hidden">
<div className={`text-7xl ${alwaysOn ? 'font-light' : 'font-bold'}`}>{timeStr}</div> <div
<div className="text-2xl text-gray-400">{dateStr}</div> className="tabular-nums leading-none"
style={{ fontSize, fontWeight: alwaysOn ? 300 : 700, letterSpacing: '-0.02em' }}
>
{timeStr}
</div>
{showDate && (
<div className="text-gray-400" style={{ fontSize: dateFontSize, marginTop: `${fontSize * 0.08}px` }}>
{dateStr}
</div>
)}
</div> </div>
); );
}; };

View file

@ -2,19 +2,33 @@ import { LineBadge } from '../atoms/LineBadge';
import { Departure } from '../../types'; import { Departure } from '../../types';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; 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 alwaysOn = useAlwaysOn();
const depTime = new Date(departure.scheduled); const depTime = new Date(departure.scheduled);
const diffMin = Math.round((depTime.getTime() - Date.now()) / 60000); const diffMin = Math.round((depTime.getTime() - Date.now()) / 60000);
const timeText = diffMin <= 0 ? 'Now' : `${diffMin} min`; const timeText = diffMin <= 0 ? 'Now' : `${diffMin} min`;
return ( return (
<div className={`flex justify-between items-center py-3 border-b border-zinc-700 text-2xl ${alwaysOn ? 'font-light' : ''}`}> <div
<div className="flex items-center gap-4"> className="flex justify-between items-center border-b border-zinc-700 shrink-0"
<LineBadge designation={departure.line.designation} mode={departure.line.transport_mode} groupOfLines={departure.line.group_of_lines} /> style={{ height: rowHeight, fontSize: Math.max(11, rowHeight * 0.40) }}
<span>{departure.destination}</span> >
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
<LineBadge
designation={departure.line.designation}
mode={departure.line.transport_mode}
groupOfLines={departure.line.group_of_lines}
size={rowHeight * 0.65}
/>
<span className="truncate">{departure.destination}</span>
</div> </div>
<span className={alwaysOn ? 'font-normal' : 'font-bold'}>{timeText}</span> <span className={`shrink-0 ml-3 ${alwaysOn ? '' : 'font-bold'}`}>{timeText}</span>
</div> </div>
); );
}; };

View file

@ -2,21 +2,46 @@ import { SBBLineBadge } from '../atoms/SBBLineBadge';
import { SBBDeparture } from '../../types'; import { SBBDeparture } from '../../types';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; 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 alwaysOn = useAlwaysOn();
const dep = departure.stop.departure ? new Date(departure.stop.departure) : null; const dep = departure.stop.departure ? new Date(departure.stop.departure) : null;
const diffMin = dep ? Math.round((dep.getTime() - Date.now()) / 60000) : null; const diffMin = dep ? Math.round((dep.getTime() - Date.now()) / 60000) : null;
const timeText = diffMin === null ? '—' : diffMin <= 0 ? 'Now' : `${diffMin} min`; 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 ( return (
<div className={`flex justify-between items-center py-3 border-b border-zinc-700 text-2xl ${alwaysOn ? 'font-light' : ''}`}> <div
<div className="flex items-center gap-4"> className="flex justify-between items-center border-b border-zinc-700 shrink-0"
<SBBLineBadge name={`${departure.category} ${departure.number}`} category={departure.category} /> style={{ height: rowHeight, fontSize }}
<span>{departure.to}</span> >
<div className="flex items-center gap-3 min-w-0 overflow-hidden">
<SBBLineBadge
name={`${departure.category} ${departure.number}`}
category={departure.category}
size={rowHeight * 0.65}
/>
<span className="truncate">{departure.to}</span>
</div> </div>
<div className={`flex items-center gap-2 ${alwaysOn ? 'font-normal' : 'font-bold'}`}> <div className={`flex items-center gap-2 shrink-0 ml-3 ${alwaysOn ? '' : 'font-bold'}`}>
{delay != null && delay > 0 && <span className="text-lg font-normal">+{delay}'</span>} {showPlatform && departure.stop.platform && (
<span className="text-gray-400 font-normal" style={{ fontSize: fontSize * 0.75 }}>
Pl.{departure.stop.platform}
</span>
)}
{delay != null && delay > 0 && (
<span className="text-orange-400 font-normal" style={{ fontSize: fontSize * 0.75 }}>
+{delay}'
</span>
)}
<span>{timeText}</span> <span>{timeText}</span>
</div> </div>
</div> </div>

View file

@ -1,14 +1,58 @@
import { useSize } from '../../hooks/useSize';
import { DepartureItem } from '../molecules/DepartureItem'; import { DepartureItem } from '../molecules/DepartureItem';
import { Departure } from '../../types'; import { Departure } from '../../types';
export const DepartureList = ({ departures, loading }: { departures: Departure[]; loading: boolean }) => { export const DepartureList = ({
if (loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">Loading...</p>; departures,
if (!loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">No departures.</p>; 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 (
<div ref={ref} className="w-full h-full flex items-center">
<span className="text-gray-500 text-sm">Loading</span>
</div>
);
}
return ( return (
<div className="flex-1 overflow-hidden"> <div ref={ref} className="w-full h-full flex flex-col overflow-hidden">
{departures.map((dep, i) => ( {stationName && (
<DepartureItem key={`${dep.line.designation}-${i}`} departure={dep} /> <div
))} className="text-gray-200 uppercase tracking-wider font-bold shrink-0 flex items-center border-b border-zinc-600"
style={{ height: headerH, fontSize: headerFontSz }}
>
{stationName}
</div>
)}
{visible.length === 0 ? (
<span className="text-gray-500 mt-2 text-sm">No departures</span>
) : (
visible.map((dep, i) => (
<DepartureItem
key={`${dep.line.designation}-${dep.scheduled}-${i}`}
departure={dep}
rowHeight={rowH}
/>
))
)}
</div> </div>
); );
}; };

View file

@ -1,14 +1,60 @@
import { useSize } from '../../hooks/useSize';
import { SBBDepartureItem } from '../molecules/SBBDepartureItem'; import { SBBDepartureItem } from '../molecules/SBBDepartureItem';
import { SBBDeparture } from '../../types'; import { SBBDeparture } from '../../types';
export const SBBDepartureList = ({ departures, loading }: { departures: SBBDeparture[]; loading: boolean }) => { export const SBBDepartureList = ({
if (loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">Loading...</p>; departures,
if (!loading && departures.length === 0) return <p className="text-gray-500 text-xl mt-4">No departures.</p>; 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 (
<div ref={ref} className="w-full h-full flex items-center">
<span className="text-gray-500 text-sm">Loading</span>
</div>
);
}
return ( return (
<div className="flex-1 overflow-hidden"> <div ref={ref} className="w-full h-full flex flex-col overflow-hidden">
{departures.map((dep, i) => ( {stationName && (
<SBBDepartureItem key={`${dep.name}-${i}`} departure={dep} /> <div
))} className="text-gray-200 uppercase tracking-wider font-bold shrink-0 flex items-center border-b border-zinc-600"
style={{ height: headerH, fontSize: headerFontSz }}
>
{stationName}
</div>
)}
{visible.length === 0 ? (
<span className="text-gray-500 mt-2 text-sm">No departures</span>
) : (
visible.map((dep, i) => (
<SBBDepartureItem
key={`${dep.name}-${dep.stop.departure}-${i}`}
departure={dep}
rowHeight={rowH}
showPlatform={showPlatform}
/>
))
)}
</div> </div>
); );
}; };

View file

@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
import { useSize } from '../../hooks/useSize';
import { SpotifyNowPlaying } from '../../types'; import { SpotifyNowPlaying } from '../../types';
function fmtMs(ms: number) { function fmtMs(ms: number) {
@ -9,36 +10,78 @@ function fmtMs(ms: number) {
export const SpotifyWidget = () => { export const SpotifyWidget = () => {
const alwaysOn = useAlwaysOn(); const alwaysOn = useAlwaysOn();
const { w, h, ref } = useSize();
const { data } = useQuery<SpotifyNowPlaying>({ const { data } = useQuery<SpotifyNowPlaying>({
queryKey: ['spotify-now-playing'], queryKey: ['spotify-now-playing'],
queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()), queryFn: () => fetch('/api/spotify/now-playing').then(r => r.json()),
refetchInterval: alwaysOn ? 30_000 : 1_000, 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 ( return (
<div className="h-full flex flex-col justify-center"> <div ref={ref} className="w-full h-full flex flex-col justify-center overflow-hidden">
<h2 className="text-sm uppercase tracking-wider text-gray-400 mb-1 font-normal">Now Playing</h2> {showLabel && (
<div className="text-gray-400 uppercase tracking-wider" style={{ fontSize: labelFS, marginBottom: '0.3em' }}>
Now Playing
</div>
)}
{data?.playing && data.track ? ( {data?.playing && data.track ? (
<> <>
<p className={`text-2xl leading-tight ${alwaysOn ? 'font-normal' : 'font-bold'}`}>{data.track.name}</p> <div
<p className="text-lg text-gray-300 mt-0.5">{data.track.artist}</p> className="leading-tight truncate"
<p className="text-sm text-gray-500">{data.track.album}</p> style={{ fontSize: trackFS, fontWeight: alwaysOn ? 400 : 700 }}
{!alwaysOn && ( >
<div className="mt-1.5 flex items-center gap-2"> {data.track.name}
<span className="text-xs text-gray-500 font-mono w-10 shrink-0">{fmtMs(data.track.progressMs)}</span> </div>
<div className="flex-1 h-1.5 bg-zinc-800 relative"> {showArtist && (
<div className="text-gray-300 truncate" style={{ fontSize: artistFS, marginTop: '0.15em' }}>
{data.track.artist}
</div>
)}
{showAlbum && (
<div className="text-gray-500 truncate" style={{ fontSize: albumFS, marginTop: '0.1em' }}>
{data.track.album}
</div>
)}
{showProgress && (
<div className="flex items-center gap-2" style={{ marginTop: '0.4em' }}>
{showProgressTime && (
<span className="text-gray-500 font-mono shrink-0" style={{ fontSize: albumFS }}>
{fmtMs(data.track.progressMs)}
</span>
)}
<div className="flex-1 bg-zinc-800 relative" style={{ height: barH }}>
<div <div
className="absolute inset-y-0 left-0 bg-white" className="absolute inset-y-0 left-0 bg-white"
style={{ width: `${(data.track.progressMs / data.track.durationMs) * 100}%` }} style={{ width: `${(data.track.progressMs / data.track.durationMs) * 100}%` }}
/> />
</div> </div>
<span className="text-xs text-gray-500 font-mono w-10 shrink-0 text-right">{fmtMs(data.track.durationMs)}</span> {showProgressTime && (
<span className="text-gray-500 font-mono shrink-0 text-right" style={{ fontSize: albumFS }}>
{fmtMs(data.track.durationMs)}
</span>
)}
</div> </div>
)} )}
</> </>
) : ( ) : (
<p className="text-gray-500 text-lg">Nothing playing</p> <div className="text-gray-500" style={{ fontSize: artistFS }}>Nothing playing</div>
)} )}
</div> </div>
); );

View file

@ -1,48 +1,91 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAlwaysOn } from '../../contexts/AlwaysOnContext'; import { useAlwaysOn } from '../../contexts/AlwaysOnContext';
import { useSize } from '../../hooks/useSize';
interface SystemData { interface SystemData {
cpu: { percent: number; load: string[] }; cpu: { percent: number; load: string[] };
memory: { percent: number; usedGb: string; totalGb: string }; memory: { percent: number; usedGb: string; totalGb: string };
disk: { percent: number; usedGb: string; totalGb: string }; disk: { percent: number; usedGb: string; totalGb: string };
uptime: string; uptime: string;
hostname: string; hostname: string;
} }
const Bar = ({ percent }: { percent: number }) => ( const MetricRow = ({
<div className="flex-1 h-1.5 bg-zinc-800 relative"> label, percent, detail, rowH, barH, fontSize,
<div className="absolute inset-y-0 left-0 bg-white" style={{ width: `${percent}%` }} /> }: {
</div> label: string; percent: number; detail: string;
); rowH: number; barH: number; fontSize: number;
}) => (
const Row = ({ label, percent, detail }: { label: string; percent: number; detail: string }) => ( <div className="flex items-center gap-2 font-mono shrink-0" style={{ height: rowH, fontSize }}>
<div className="flex items-center gap-3 text-sm font-mono"> <span className="text-gray-400 shrink-0" style={{ width: '3.2em' }}>{label}</span>
<span className="w-10 text-gray-400 shrink-0">{label}</span> <div className="flex-1 min-w-0 bg-zinc-800 relative" style={{ height: barH }}>
<Bar percent={percent} /> <div className="absolute inset-y-0 left-0 bg-white" style={{ width: `${percent}%` }} />
<span className="w-8 text-right shrink-0">{percent}%</span> </div>
<span className="text-gray-500 shrink-0">{detail}</span> <span className="text-gray-500 shrink-0">{detail}</span>
</div> </div>
); );
export const SystemWidget = () => { export const SystemWidget = () => {
const alwaysOn = useAlwaysOn(); const alwaysOn = useAlwaysOn();
const { w, h, ref } = useSize();
const { data } = useQuery<SystemData>({ const { data } = useQuery<SystemData>({
queryKey: ['system'], queryKey: ['system'],
queryFn: () => fetch('/api/system').then(r => r.json()), queryFn: () => fetch('/api/system').then(r => r.json()),
refetchInterval: alwaysOn ? 60_000 : 2_000, refetchInterval: alwaysOn ? 60_000 : 2_000,
}); });
if (!data) return null; if (!data) return <div ref={ref} className="w-full h-full" />;
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 ( return (
<div className="h-full flex flex-col justify-center gap-3"> <div ref={ref} className="w-full h-full flex flex-col justify-center overflow-hidden" style={{ gap: 5 }}>
<div className="flex justify-between items-baseline"> {showHostname && (
<span className="font-bold text-lg">{data.hostname}</span> <div className="flex justify-between items-baseline shrink-0" style={{ height: hostH }}>
<span className="text-gray-500 text-sm font-mono">up {data.uptime}</span> <span className="font-bold truncate" style={{ fontSize: hostFontSz }}>{data.hostname}</span>
</div> {showUptime && (
<Row label="CPU" percent={data.cpu.percent} detail={`load ${data.cpu.load[0]} ${data.cpu.load[1]} ${data.cpu.load[2]}`} /> <span className="text-gray-500 font-mono shrink-0 ml-2" style={{ fontSize: hostFontSz * 0.65 }}>
<Row label="MEM" percent={data.memory.percent} detail={`${data.memory.usedGb} / ${data.memory.totalGb} GB`} /> up {data.uptime}
<Row label="DISK" percent={data.disk.percent} detail={`${data.disk.usedGb} / ${data.disk.totalGb} GB`} /> </span>
)}
</div>
)}
<MetricRow
label="CPU"
percent={data.cpu.percent}
detail={showLoadDetail ? data.cpu.load.join(' ') : `${data.cpu.percent}%`}
rowH={rowH} barH={barH} fontSize={fontSize}
/>
<MetricRow
label="MEM"
percent={data.memory.percent}
detail={`${data.memory.usedGb}/${data.memory.totalGb}G`}
rowH={rowH} barH={barH} fontSize={fontSize}
/>
{showDisk && (
<MetricRow
label="DISK"
percent={data.disk.percent}
detail={`${data.disk.usedGb}/${data.disk.totalGb}G`}
rowH={rowH} barH={barH} fontSize={fontSize}
/>
)}
</div> </div>
); );
}; };

View file

@ -1,135 +1,103 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useSize } from '../../hooks/useSize';
import { WeatherData } from '../../types'; import { WeatherData } from '../../types';
// 5-line ASCII art keyed by condition keyword
const ASCII: Record<string, string[]> = { const ASCII: Record<string, string[]> = {
sunny: [ sunny: [' \\ / ', ' .-. ', ' ― ( ) ―', ' `-\' ', ' / \\ '],
' \\ / ', partlycloudy: [' \\ / ', ' _ /"".--. ', ' \\_( { ) ', ' `-\'--\' ', ' '],
' .-. ', cloudy: [' ', ' .--. ', ' .-( ). ', '(___.__)__) ', ' '],
' ― ( ) ―', overcast: [' ', ' .------. ', '.-( ).', '(__________.) ', ' '],
' `-\' ', rain: [' .--. ', ' .-( ). ', '(___.__)__) ', " ʻ ʻ ʻ ʻ ", " ʻ ʻ ʻ ʻ "],
' / \\ ', drizzle: [' .--. ', ' .-( ). ', '(___.__)__) ', " , , , , ", " "],
], snow: [' .--. ', ' .-( ). ', '(___.__)__) ', ' * * * ', '* * * * '],
partlycloudy: [ thunder: [' .--. ', ' .-( ). ', '(___.__)__) ', ' / \\ ', ' / \\ '],
' \\ / ', fog: [' ', ' _ - _ - _ -', ' _ - _ - _ ', ' _ - _ - _ -', ' '],
' _ /"".--. ', sleet: [' .--. ', ' .-( ). ', '(___.__)__) ', " * ʻ * ʻ ", " ʻ * ʻ * "],
' \\_( { ) ',
' `-\'--\' ',
' ',
],
cloudy: [
' ',
' .--. ',
' .-( ). ',
'(___.__)__) ',
' ',
],
overcast: [
' ',
' .------. ',
'.-( ).',
'(__________.) ',
' ',
],
rain: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" ʻ ʻ ʻ ʻ ",
" ʻ ʻ ʻ ʻ ",
],
drizzle: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" , , , , ",
" ",
],
snow: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
' * * * ',
'* * * * ',
],
thunder: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
' / \\ ',
' / \\ ',
],
fog: [
' ',
' _ - _ - _ -',
' _ - _ - _ ',
' _ - _ - _ -',
' ',
],
sleet: [
' .--. ',
' .-( ). ',
'(___.__)__) ',
" * ʻ * ʻ ",
" ʻ * ʻ * ",
],
}; };
const MOON_CHAR: Record<string, string> = { const MOON_CHAR: Record<string, string> = {
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓', 'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓', 'Waxing Gibbous': '🌔',
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖', 'Full Moon': '🌕', 'Waning Gibbous': '🌖', 'Last Quarter': '🌗', 'Waning Crescent': '🌘',
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
}; };
function getAsciiKey(desc: string): string { function getAsciiKey(desc: string): string {
const d = desc.toLowerCase(); const d = desc.toLowerCase();
if (d.includes('thunder')) return 'thunder'; if (d.includes('thunder')) return 'thunder';
if (d.includes('snow')) return 'snow'; if (d.includes('snow')) return 'snow';
if (d.includes('sleet')) return 'sleet'; if (d.includes('sleet')) return 'sleet';
if (d.includes('drizzle')) return 'drizzle'; if (d.includes('drizzle')) return 'drizzle';
if (d.includes('rain')) return 'rain'; if (d.includes('rain')) return 'rain';
if (d.includes('fog') || d.includes('mist') || d.includes('haze')) return 'fog'; if (d.includes('fog') || d.includes('mist')) return 'fog';
if (d.includes('overcast')) return 'overcast'; if (d.includes('overcast')) return 'overcast';
if (d.includes('mostly cloudy') || d.includes('very cloudy')) return 'cloudy'; if (d.includes('mostly cloudy') || d.includes('very')) return 'cloudy';
if (d.includes('partly cloudy') || d.includes('mostly sunny')) return 'partlycloudy'; if (d.includes('partly') || d.includes('mostly sunny')) return 'partlycloudy';
if (d.includes('cloudy')) return 'cloudy'; if (d.includes('cloudy')) return 'cloudy';
if (d.includes('sunny') || d.includes('clear')) return 'sunny'; if (d.includes('sunny') || d.includes('clear')) return 'sunny';
return 'cloudy'; return 'cloudy';
} }
export const WeatherWidget = () => { export const WeatherWidget = () => {
const { w, h, ref } = useSize();
const { data, isError } = useQuery<WeatherData>({ const { data, isError } = useQuery<WeatherData>({
queryKey: ['weather'], queryKey: ['weather'],
queryFn: () => fetch('/api/weather').then(r => r.json()), queryFn: () => fetch('/api/weather').then(r => r.json()),
refetchInterval: 10 * 60 * 1000, refetchInterval: 10 * 60 * 1000,
}); });
const effectiveH = h || 100;
const effectiveW = w || 350;
if (isError || !data || (data as any).error) { if (isError || !data || (data as any).error) {
return <div className="text-gray-500 text-sm">Weather unavailable</div>; return (
<div ref={ref} className="w-full h-full flex items-center">
<span className="text-gray-500 text-sm">Weather unavailable</span>
</div>
);
} }
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; const moon = MOON_CHAR[data.moonPhase] ?? data.moonPhase;
return ( return (
<div className="flex items-start gap-4"> <div ref={ref} className="w-full h-full flex items-center gap-5 overflow-hidden">
<pre className="text-gray-300 text-[11px] leading-tight select-none font-mono">{art.join('\n')}</pre> {showAscii && (
<div className="flex flex-col gap-1"> <pre
<div className="flex items-baseline gap-3"> className="text-gray-300 shrink-0 select-none font-mono leading-tight"
<span className="text-4xl font-bold">{data.temperature}°C</span> style={{ fontSize: asciiFontSize }}
<span className="text-lg text-gray-300">{data.description}</span> >
{art.join('\n')}
</pre>
)}
<div className="flex flex-col gap-1 min-w-0 overflow-hidden">
<div className="flex items-baseline gap-3 flex-wrap">
<span style={{ fontSize: tempFontSize, fontWeight: 700, lineHeight: 1 }}>{data.temperature}°C</span>
<span style={{ fontSize: descFontSize }} className="text-gray-300">{data.description}</span>
</div> </div>
<div className="flex gap-4 text-sm text-gray-400 font-mono"> {showSunMoon && (
<span> {data.sunrise}</span> <div className="flex flex-wrap gap-3 text-gray-400 font-mono" style={{ fontSize: metaFontSize }}>
<span> {data.sunset}</span> <span> {data.sunrise}</span>
<span title={data.moonPhase}>{moon} {data.moonPhase}</span> <span> {data.sunset}</span>
</div> <span title={data.moonPhase}>{moon} {data.moonPhase}</span>
{data.hourly.length > 0 && ( </div>
<div className="flex gap-3 mt-1"> )}
{data.hourly.map((h, i) => ( {showHourly && data.hourly.length > 0 && (
<div key={i} className="text-center"> <div className="flex gap-4 mt-1">
<div className="text-xs text-gray-500">{h.time}</div> {data.hourly.slice(0, maxHours).map((entry, i) => (
<div className="text-sm font-bold">{h.temp}°</div> <div key={i} className="text-center shrink-0">
<div style={{ fontSize: metaFontSize * 0.85 }} className="text-gray-500">{entry.time}</div>
<div style={{ fontSize: metaFontSize }} className="font-bold">{entry.temp}°</div>
</div> </div>
))} ))}
</div> </div>

19
src/hooks/useSize.ts Normal file
View file

@ -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<ResizeObserver | null>(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 };
}

View file

@ -11,8 +11,10 @@ import { Config, Departure, SBBDeparture, WidgetId, ZoneId } from '../types';
const fetchJson = (url: string) => fetch(url).then(r => r.json()); const fetchJson = (url: string) => fetch(url).then(r => r.json());
const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3']; const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3'];
const DEPARTURE_WIDGETS: WidgetId[] = ['sl', 'sbb']; const DEPARTURE_IDS: WidgetId[] = ['sl', 'sbb'];
// ── Dashboard ─────────────────────────────────────────────────────────────────
export const Dashboard = () => { export const Dashboard = () => {
const { data: config } = useQuery<Config>({ const { data: config } = useQuery<Config>({
@ -21,121 +23,110 @@ export const Dashboard = () => {
}); });
const alwaysOn = !!config?.alwaysOn; const alwaysOn = !!config?.alwaysOn;
const layout = config?.layout ?? {}; const layout = config?.layout ?? {};
const inLayout = (id: WidgetId) => Object.values(layout).includes(id); 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); const [, tick] = useReducer(x => x + 1, 0);
useEffect(() => { useEffect(() => {
if (!alwaysOn) return; if (!alwaysOn) return;
let id: ReturnType<typeof setInterval>; let interval: ReturnType<typeof setInterval>;
const schedule = () => { const now = new Date();
const now = new Date(); const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); const timeout = setTimeout(() => { tick(); interval = setInterval(tick, 60_000); }, msUntilNext);
return setTimeout(() => { tick(); id = setInterval(tick, 60_000); }, msUntilNext); return () => { clearTimeout(timeout); clearInterval(interval); };
};
const timeout = schedule();
return () => { clearTimeout(timeout); clearInterval(id); };
}, [alwaysOn]); }, [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], queryKey: ['sl-departures', config?.sl?.stationId],
queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`), queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`),
enabled: inLayout('sl') && !!config?.sl?.stationId, enabled: inLayout('sl') && !!config?.sl?.stationId,
refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000),
}); });
const { data: sbbData, isLoading: sbbLoading } = useQuery<{ stationboard: SBBDeparture[] }>({ const { data: sbbData, isLoading: sbbLoading } = useQuery<{ stationboard: SBBDeparture[] }>({
queryKey: ['sbb-departures', config?.sbb?.stationId], queryKey: ['sbb-departures', config?.sbb?.stationId],
queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`), queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`),
enabled: inLayout('sbb') && !!config?.sbb?.stationId, enabled: inLayout('sbb') && !!config?.sbb?.stationId,
refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), 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) => { const renderWidget = (id: WidgetId | undefined) => {
if (!id || id === 'spacer') return null; if (!id || id === 'spacer') return null;
switch (id) { switch (id) {
case 'clock': return <div className="h-full flex items-center"><Clock /></div>; case 'clock': return <Clock />;
case 'weather': return <div className="h-full flex items-center"><WeatherWidget /></div>; case 'weather': return <WeatherWidget />;
case 'spotify': return <SpotifyWidget />; case 'spotify': return <SpotifyWidget />;
case 'system': return <SystemWidget />; case 'system': return <SystemWidget />;
case 'sl': case 'sl': return <DepartureList departures={slData?.departures ?? []} loading={slLoading} stationName={slName} />;
return ( case 'sbb': return <SBBDepartureList departures={sbbData?.stationboard ?? []} loading={sbbLoading} stationName={sbbName} />;
<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>
);
} }
}; };
// ── Layout derivations ───────────────────────────────────────────────────────
const headerLeft = layout['header-left']; const headerLeft = layout['header-left'];
const headerRight = layout['header-right']; const headerRight = layout['header-right'];
const main1 = layout['main-1']; const main1 = layout['main-1'];
const main2 = layout['main-2']; 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 is WidgetId => w != null);
const footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter(w => w != null) as WidgetId[];
const hasHeader = headerLeft || headerRight; const hasHeader = !!(headerLeft || headerRight);
// spacer alone doesn't count as real content const hasMain = !!(main1 && main1 !== 'spacer') || !!(main2 && main2 !== 'spacer');
const hasMain = (main1 && main1 !== 'spacer') || (main2 && main2 !== 'spacer'); const hasFooter = footerWidgets.some(w => w !== 'spacer');
const hasFooter = footerWidgets.filter(w => w !== 'spacer').length > 0; 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. // Footer height: departure lists need more room; info-only widgets get 20 vh
// When only info widgets: auto-height. When no main: expands to fill.
const footerStyle: React.CSSProperties = !hasMain const footerStyle: React.CSSProperties = !hasMain
? { flex: '1 1 0', minHeight: 0 } ? { flex: '1 1 0', minHeight: 0 }
: footerHasDeps : footerHasDeps
? { flex: '0 0 40%', minHeight: 0 } ? { 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 // ── Render ───────────────────────────────────────────────────────────────────
const zoneClass = 'min-h-0 min-w-0 overflow-hidden';
return ( return (
<AlwaysOnContext.Provider value={alwaysOn}> <AlwaysOnContext.Provider value={alwaysOn}>
<div className="flex flex-col h-screen w-screen p-6 gap-4 overflow-hidden"> <div className="flex flex-col h-screen w-screen p-6 gap-4 overflow-hidden">
{hasHeader && ( {hasHeader && (
<div className="flex items-center justify-between border-b border-white pb-4 shrink-0"> <div
<div className={`flex-1 ${zoneClass}`}>{renderWidget(headerLeft)}</div> className="flex items-stretch justify-between border-b border-white pb-4 shrink-0"
{headerRight && <div className={`flex-shrink-0 ml-8 ${zoneClass}`}>{renderWidget(headerRight)}</div>} style={{ minHeight: '72px' }}
>
<div className={`flex-1 ${zone}`}>{renderWidget(headerLeft)}</div>
{headerRight && (
<div className={`flex-shrink-0 ml-8 ${zone}`}>{renderWidget(headerRight)}</div>
)}
</div> </div>
)} )}
{hasMain && ( {hasMain && (
<div <div
className={`${main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}`} className={main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}
style={{ flex: '1 1 0', minHeight: 0 }} style={{ flex: '1 1 0', minHeight: 0 }}
> >
{main1 && <div className={`${main1 && main2 ? '' : 'flex-1'} ${zoneClass}`}>{renderWidget(main1)}</div>} {main1 && <div className={`${!main2 ? 'flex-1' : ''} ${zone}`}>{renderWidget(main1)}</div>}
{main2 && <div className={zoneClass}>{renderWidget(main2)}</div>} {main2 && <div className={zone}>{renderWidget(main2)}</div>}
</div> </div>
)} )}
{hasFooter && ( {hasFooter && (
<div <div
className="border-t border-white pt-4 grid gap-6" className="border-t border-white pt-4 grid gap-6"
style={{ style={{ ...footerStyle, gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)` }}
...footerStyle,
gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)`,
}}
> >
{footerWidgets.map((w, i) => ( {footerWidgets.map((w, i) => (
<div key={`${w}-${i}`} className={`${zoneClass} h-full`}>{renderWidget(w)}</div> <div key={`${w}-${i}`} className={zone}>{renderWidget(w)}</div>
))} ))}
</div> </div>
)} )}