diff --git a/package.json b/package.json index 0a62326..0adc54d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bodenfeuchte", "private": true, - "version": "1.0.1", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src/lib/db.ts b/src/lib/db.ts index da472f9..1d5febc 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -27,8 +27,8 @@ export function insertMeasurement(soilMoisture: number): void { stmt.run(Date.now(), soilMoisture); } -export function getLast6Hours(): { timestamp: number; soil_moisture: number }[] { - const since = Date.now() - 6 * 60 * 60 * 1000; +export function getMeasurementsSince(hours: number): { timestamp: number; soil_moisture: number }[] { + const since = Date.now() - hours * 60 * 60 * 1000; return getDb() .prepare( 'SELECT timestamp, soil_moisture FROM measurements WHERE timestamp >= ? ORDER BY timestamp ASC' diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a648e35..418d076 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,6 +8,53 @@ type Measurement = { timestamp: number; soil_moisture: number }; + type Range = { + label: string; + hours: number; + labelText: string; + step: number; + isMajor: (d: Date) => boolean; + formatTick: (d: Date, major: boolean) => string | string[]; + }; + + const pad = (n: number) => String(n).padStart(2, '0'); + + const RANGES: Range[] = [ + { + label: '6h', hours: 6, labelText: 'Letzte 6 Stunden', + step: 10 * 60 * 1000, + isMajor: (d) => d.getMinutes() === 0, + formatTick: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }, + { + label: '12h', hours: 12, labelText: 'Letzte 12 Stunden', + step: 30 * 60 * 1000, + isMajor: (d) => d.getMinutes() === 0, + formatTick: (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }, + { + label: '1d', hours: 24, labelText: 'Letzter Tag', + step: 60 * 60 * 1000, + isMajor: (d) => d.getHours() % 6 === 0, + formatTick: (d) => `${pad(d.getHours())}:00`, + }, + { + label: '7d', hours: 7 * 24, labelText: 'Letzte 7 Tage', + step: 6 * 60 * 60 * 1000, + isMajor: (d) => d.getHours() === 0, + formatTick: (d, major) => major + ? ['00:00', `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`] + : `${pad(d.getHours())}:00`, + }, + { + label: '30d', hours: 30 * 24, labelText: 'Letzte 30 Tage', + step: 24 * 60 * 60 * 1000, + isMajor: (d) => d.getDay() === 1, + formatTick: (d) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`, + }, + ]; + + let selectedRange = $state(0); let data = $state([]); let lastUpdate = $state(''); let canvas: HTMLCanvasElement; @@ -15,17 +62,26 @@ let interval: ReturnType; async function fetchData() { - const res = await fetch(`${base}/api/data`); + const res = await fetch(`${base}/api/data?hours=${RANGES[selectedRange].hours}`); data = await res.json(); lastUpdate = new Date().toLocaleTimeString('de-DE'); updateChart(); } + async function selectRange(idx: number) { + selectedRange = idx; + await fetchData(); + } + function updateChart() { if (!chart) return; const now = Date.now(); + const range = RANGES[selectedRange]; + const showPoints = range.hours <= 24; chart.data.datasets[0].data = data.map(d => ({ x: d.timestamp, y: d.soil_moisture })); - chart.options.scales!['x']!.min = now - 6 * 60 * 60 * 1000; + (chart.data.datasets[0] as any).pointRadius = showPoints ? 3 : 0; + (chart.data.datasets[0] as any).pointHoverRadius = showPoints ? 4 : 0; + chart.options.scales!['x']!.min = now - range.hours * 60 * 60 * 1000; chart.options.scales!['x']!.max = now; chart.update(); } @@ -60,21 +116,42 @@ }, adapters: { date: { locale: de } }, afterBuildTicks(scale: any) { - // Rebuild ticks at exactly 10-minute intervals + const range = RANGES[selectedRange]; const min = scale.min; const max = scale.max; - const step = 10 * 60 * 1000; - const start = Math.ceil(min / step) * step; - const ticks = []; - for (let t = start; t <= max; t += step) { - const d = new Date(t); - ticks.push({ value: t, major: d.getMinutes() === 0 }); + const step = range.step; + const ticks: { value: number; major: boolean }[] = []; + + if (step < 60 * 60 * 1000) { + // Sub-hour: UTC alignment is fine (minutes are TZ-invariant) + const start = Math.ceil(min / step) * step; + for (let t = start; t <= max; t += step) { + ticks.push({ value: t, major: range.isMajor(new Date(t)) }); + } + } else { + // Hour+: align to local midnight so isMajor works in any timezone + const isDaily = step === 24 * 60 * 60 * 1000; + const d = new Date(min); + d.setHours(0, 0, 0, 0); + const advance = () => isDaily ? d.setDate(d.getDate() + 1) : d.setTime(d.getTime() + step); + if (d.getTime() < min) advance(); + while (d.getTime() <= max) { + ticks.push({ value: d.getTime(), major: range.isMajor(d) }); + advance(); + } } + scale.ticks = ticks; }, ticks: { major: { enabled: true }, autoSkip: false, + callback: (value: any, index: number, ticks: any[]) => { + const range = RANGES[selectedRange]; + const d = new Date(value); + const major = ticks[index]?.major ?? false; + return range.formatTick(d, major); + }, color: (ctx: any) => ctx.tick?.major ? '#222' : '#aaa', font: (ctx: any) => ctx.tick?.major ? { weight: 'bold' as const, size: 13 } : { size: 10 }, maxRotation: 0, @@ -107,6 +184,7 @@ }); const latest = $derived(data.at(-1)); + const currentRangeLabel = $derived(RANGES[selectedRange].labelText); @@ -140,9 +218,22 @@ {/if} + +
+ {#each RANGES as range, i} + + {/each} +
+
-

Letzte 6 Stunden · Stand: {lastUpdate || '…'}

+

{currentRangeLabel} · Stand: {lastUpdate || '…'}

{#if data.length === 0}
Noch keine Daten vorhanden diff --git a/src/routes/api/data/+server.ts b/src/routes/api/data/+server.ts index c8a5284..e86448d 100644 --- a/src/routes/api/data/+server.ts +++ b/src/routes/api/data/+server.ts @@ -1,6 +1,8 @@ import { json } from '@sveltejs/kit'; -import { getLast6Hours } from '$lib/db'; +import { getMeasurementsSince } from '$lib/db'; -export function GET() { - return json(getLast6Hours()); +export function GET({ url }: { url: URL }) { + const hours = Number(url.searchParams.get('hours') ?? 6); + const safeHours = isFinite(hours) && hours > 0 ? hours : 6; + return json(getMeasurementsSince(safeHours)); }