Zeitbereich-Buttons und dynamische X-Achse (6h/12h/1d/7d/30d)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 11:05:20 +02:00
parent 0a3e0fc2c1
commit b8b5fb3a5e
4 changed files with 109 additions and 16 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "bodenfeuchte",
"private": true,
"version": "1.0.1",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+2 -2
View File
@@ -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'
+99 -8
View File
@@ -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<Measurement[]>([]);
let lastUpdate = $state('');
let canvas: HTMLCanvasElement;
@@ -15,17 +62,26 @@
let interval: ReturnType<typeof setInterval>;
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 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;
const ticks = [];
for (let t = start; t <= max; t += step) {
const d = new Date(t);
ticks.push({ value: t, major: d.getMinutes() === 0 });
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);
</script>
<svelte:head>
@@ -140,9 +218,22 @@
{/if}
</div>
<!-- Zeitbereich-Auswahl -->
<div class="flex gap-2 justify-center flex-wrap px-2">
{#each RANGES as range, i}
<button
onclick={() => selectRange(i)}
class="px-5 py-1.5 border-2 border-black rounded font-bold text-sm"
style={selectedRange === i ? 'background:#2980b9; color:white;' : 'background:#FFFFDD; color:black;'}
>
{range.label}
</button>
{/each}
</div>
<!-- Diagramm -->
<div style="background:white; height:340px;" class="border-y-2 sm:border-2 border-black sm:rounded-lg p-4 pb-8">
<p class="text-xs text-gray-400 mb-2">Letzte 6 Stunden · Stand: {lastUpdate || '…'}</p>
<p class="text-xs text-gray-400 mb-2">{currentRangeLabel} · Stand: {lastUpdate || '…'}</p>
{#if data.length === 0}
<div class="flex items-center justify-center h-full text-gray-400 text-sm">
Noch keine Daten vorhanden
+5 -3
View File
@@ -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));
}