Mit grafischer Auswertung
This commit is contained in:
@@ -9,9 +9,20 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = parseInt(searchParams.get('limit') || '10', 10);
|
||||
const from = searchParams.get('from');
|
||||
const to = searchParams.get('to');
|
||||
|
||||
const sql = `SELECT ID, DATE_FORMAT(Datum, '%Y-%m-%d') as Datum, Zeit, Zucker, Essen, Gewicht, DruckD, DruckS, Puls FROM ${TABLE} ORDER BY Datum DESC, Zeit DESC LIMIT ${limit}`;
|
||||
const rows = await query(sql);
|
||||
let sql: string;
|
||||
let params: (string | number)[] = [];
|
||||
|
||||
if (from && to) {
|
||||
sql = `SELECT ID, DATE_FORMAT(Datum, '%Y-%m-%d') as Datum, Zeit, Zucker, Essen, Gewicht, DruckD, DruckS, Puls FROM ${TABLE} WHERE Datum BETWEEN ? AND ? ORDER BY Datum ASC, Zeit ASC`;
|
||||
params = [from, to];
|
||||
} else {
|
||||
sql = `SELECT ID, DATE_FORMAT(Datum, '%Y-%m-%d') as Datum, Zeit, Zucker, Essen, Gewicht, DruckD, DruckS, Puls FROM ${TABLE} ORDER BY Datum DESC, Zeit DESC LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
const rows = await query(sql, params);
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, data: rows },
|
||||
|
||||
16
app/charts/page.tsx
Normal file
16
app/charts/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const ChartsClient = dynamic(() => import('@/components/ChartsClient'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<span className="text-gray-500">Lade Grafiken…</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function ChartsPage() {
|
||||
return <ChartsClient />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/* stylelint-disable at-rule-no-unknown */
|
||||
@import "tailwindcss";
|
||||
@source "../components/**/*.tsx";
|
||||
@source "../app/**/*.tsx";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
|
||||
10
app/page.tsx
10
app/page.tsx
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import WerteForm from '@/components/WerteForm';
|
||||
import WerteList from '@/components/WerteList';
|
||||
import { WerteEntry } from '@/types/werte';
|
||||
@@ -84,8 +85,17 @@ export default function Home() {
|
||||
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Werte - Log</h1>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Link
|
||||
href="/charts"
|
||||
className="px-4 py-2 text-sm rounded-lg transition-colors shadow-md"
|
||||
style={{ backgroundColor: '#374151', color: '#ffffff' }}
|
||||
>
|
||||
{'Verlauf ->'}
|
||||
</Link>
|
||||
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors shadow-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
|
||||
|
||||
289
components/ChartsClient.tsx
Normal file
289
components/ChartsClient.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Highcharts from 'highcharts';
|
||||
import HighchartsReact from 'highcharts-react-official';
|
||||
import type { Options } from 'highcharts';
|
||||
import { WerteEntry } from '@/types/werte';
|
||||
import LogoutButton from '@/components/LogoutButton';
|
||||
|
||||
const COLOR_ZUCKER = '#e67e22';
|
||||
const COLOR_DRUCKS = '#c0392b';
|
||||
const COLOR_DRUCKD = '#2980b9';
|
||||
const COLOR_PULS = '#27ae60';
|
||||
const COLOR_GEWICHT = '#8e44ad';
|
||||
|
||||
function getDefaultDates() {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 30);
|
||||
return {
|
||||
from: from.toISOString().split('T')[0],
|
||||
to: to.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
function toTimestamp(datum: string, zeit: string): number {
|
||||
return new Date(`${datum}T${zeit}`).getTime();
|
||||
}
|
||||
|
||||
function hasValue(v: number | string | null | undefined): boolean {
|
||||
if (v == null || v === '') return false;
|
||||
const n = parseFloat(String(v));
|
||||
return !isNaN(n) && n !== 0;
|
||||
}
|
||||
|
||||
const baseChartOptions: Partial<Options> = {
|
||||
chart: {
|
||||
type: 'line',
|
||||
backgroundColor: '#F4F4F5',
|
||||
style: { fontFamily: 'Arial, Helvetica, sans-serif' },
|
||||
height: 300,
|
||||
width: null,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime',
|
||||
dateTimeLabelFormats: {
|
||||
day: '%d.%m.',
|
||||
week: '%d.%m.',
|
||||
month: '%m.%Y',
|
||||
},
|
||||
title: { text: null },
|
||||
labels: { style: { fontSize: '11px' } },
|
||||
},
|
||||
tooltip: {
|
||||
xDateFormat: '%d.%m.%Y %H:%M',
|
||||
shared: true,
|
||||
valueDecimals: 1,
|
||||
},
|
||||
credits: { enabled: false },
|
||||
legend: { enabled: true },
|
||||
plotOptions: {
|
||||
line: {
|
||||
lineWidth: 2,
|
||||
marker: { enabled: true, radius: 2, symbol: 'circle' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function ChartsClient() {
|
||||
const defaults = getDefaultDates();
|
||||
const [from, setFrom] = useState(defaults.from);
|
||||
const [to, setTo] = useState(defaults.to);
|
||||
const [entries, setEntries] = useState<WerteEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (fromDate: string, toDate: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/werte?from=${fromDate}&to=${toDate}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setEntries(data.data);
|
||||
} else {
|
||||
setError('Fehler beim Laden der Daten.');
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(from, to);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleApply = () => fetchData(from, to);
|
||||
|
||||
// --- Build data series (sorted asc, null/zero values excluded) ---
|
||||
const zuckerData: [number, number][] = entries
|
||||
.filter(e => hasValue(e.Zucker))
|
||||
.map(e => [toTimestamp(e.Datum, e.Zeit), parseFloat(String(e.Zucker))]);
|
||||
|
||||
const druckSData: [number, number][] = entries
|
||||
.filter(e => hasValue(e.DruckS))
|
||||
.map(e => [toTimestamp(e.Datum, e.Zeit), parseFloat(String(e.DruckS))]);
|
||||
|
||||
const druckDData: [number, number][] = entries
|
||||
.filter(e => hasValue(e.DruckD))
|
||||
.map(e => [toTimestamp(e.Datum, e.Zeit), parseFloat(String(e.DruckD))]);
|
||||
|
||||
const pulsData: [number, number][] = entries
|
||||
.filter(e => hasValue(e.Puls))
|
||||
.map(e => [toTimestamp(e.Datum, e.Zeit), parseFloat(String(e.Puls))]);
|
||||
|
||||
const gewichtData: [number, number][] = entries
|
||||
.filter(e => hasValue(e.Gewicht))
|
||||
.map(e => [toTimestamp(e.Datum, e.Zeit), parseFloat(String(e.Gewicht))]);
|
||||
|
||||
// --- Chart options ---
|
||||
const zuckerOptions: Options = {
|
||||
...baseChartOptions,
|
||||
title: { text: 'Blutzucker', style: { fontWeight: 'bold', fontSize: '15px' } },
|
||||
yAxis: {
|
||||
title: { text: 'mmol/L' },
|
||||
// min: 50,
|
||||
softMin: 100,
|
||||
softMax: 170,
|
||||
gridLineColor: '#ddd',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: 'Zucker',
|
||||
data: zuckerData,
|
||||
color: COLOR_ZUCKER,
|
||||
},
|
||||
],
|
||||
} as Options;
|
||||
|
||||
const druckOptions: Options = {
|
||||
...baseChartOptions,
|
||||
title: { text: 'Blutdruck', style: { fontWeight: 'bold', fontSize: '15px' } },
|
||||
yAxis: {
|
||||
title: { text: 'mmHg' },
|
||||
softMin: 100,
|
||||
softMax: 160,
|
||||
gridLineColor: '#ddd',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: 'Druck sys',
|
||||
data: druckSData,
|
||||
color: COLOR_DRUCKS,
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
name: 'Druck dia',
|
||||
data: druckDData,
|
||||
color: COLOR_DRUCKD,
|
||||
},
|
||||
],
|
||||
} as Options;
|
||||
|
||||
const pulsOptions: Options = {
|
||||
...baseChartOptions,
|
||||
title: { text: 'Puls', style: { fontWeight: 'bold', fontSize: '15px' } },
|
||||
yAxis: {
|
||||
title: { text: 'bpm' },
|
||||
softMin: 70,
|
||||
softMax: 90,
|
||||
gridLineColor: '#ddd',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: 'Puls',
|
||||
data: pulsData,
|
||||
color: COLOR_PULS,
|
||||
},
|
||||
],
|
||||
} as Options;
|
||||
|
||||
const gewichtOptions: Options = {
|
||||
...baseChartOptions,
|
||||
title: { text: 'Gewicht', style: { fontWeight: 'bold', fontSize: '15px' } },
|
||||
yAxis: {
|
||||
title: { text: 'kg' },
|
||||
softMin: 70,
|
||||
softMax: 90,
|
||||
gridLineColor: '#ddd',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: 'Gewicht',
|
||||
data: gewichtData,
|
||||
color: COLOR_GEWICHT,
|
||||
},
|
||||
],
|
||||
} as Options;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white py-4 px-4">
|
||||
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Werte – Verlauf</h1>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-4 py-2 text-sm rounded-lg transition-colors shadow-md"
|
||||
>
|
||||
{'<- Eingabe'}
|
||||
</Link>
|
||||
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors shadow-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date range picker */}
|
||||
<div className="bg-white border border-black rounded-lg p-4 mb-6 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<label className="font-semibold text whitespace-nowrap" htmlFor="from">
|
||||
Von:
|
||||
</label>
|
||||
<input
|
||||
id="from"
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={e => setFrom(e.target.value)}
|
||||
className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<label className="font-semibold text whitespace-nowrap" htmlFor="to">
|
||||
Bis:
|
||||
</label>
|
||||
<input
|
||||
id="to"
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={e => setTo(e.target.value)}
|
||||
className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={isLoading}
|
||||
className="bg-[#85B7D7] hover:bg-[#6a9fc5] ml-20 px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Lade...' : 'Anzeigen'}
|
||||
</button>
|
||||
{error && <span className="text-red-600 text-sm">{error}</span>}
|
||||
{!isLoading && !error && entries.length === 0 && (
|
||||
<span className="text-gray-500 text-sm">Keine Daten im gewählten Zeitraum.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-gray-500">Lade Daten…</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="block w-full bg-white border border-black rounded-lg shadow-md p-3 overflow-hidden">
|
||||
<HighchartsReact highcharts={Highcharts} options={zuckerOptions} containerProps={{ style: { display: 'block', width: '100%' } }} />
|
||||
</div>
|
||||
<div className="block w-full bg-white border border-black rounded-lg shadow-md p-3 overflow-hidden">
|
||||
<HighchartsReact highcharts={Highcharts} options={druckOptions} containerProps={{ style: { display: 'block', width: '100%' } }} />
|
||||
</div>
|
||||
<div className="block w-full bg-white border border-black rounded-lg shadow-md p-3 overflow-hidden">
|
||||
<HighchartsReact highcharts={Highcharts} options={pulsOptions} containerProps={{ style: { display: 'block', width: '100%' } }} />
|
||||
</div>
|
||||
<div className="block w-full bg-white border border-black rounded-lg shadow-md p-3 overflow-hidden">
|
||||
<HighchartsReact highcharts={Highcharts} options={gewichtOptions} containerProps={{ style: { display: 'block', width: '100%' } }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "werte_next",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "werte_next",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"highcharts": "^12.5.0",
|
||||
"highcharts-react-official": "^3.2.3",
|
||||
"jose": "^6.1.3",
|
||||
"mysql2": "^3.17.4",
|
||||
"next": "16.1.6",
|
||||
@@ -3961,6 +3963,35 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highcharts": {
|
||||
"version": "12.5.0",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-12.5.0.tgz",
|
||||
"integrity": "sha512-uNSSv1KqRLNvkyXlf1FbeGWB1mJ/8/IYpVeqYiTIop5Wo8peUvNyzUfLa58vILsmXyz+XrETtgKIEOcSgBBKuQ==",
|
||||
"license": "https://www.highcharts.com/license",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"jspdf": "^3.0.0",
|
||||
"svg2pdf.js": "^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jspdf": {
|
||||
"optional": true
|
||||
},
|
||||
"svg2pdf.js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/highcharts-react-official": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/highcharts-react-official/-/highcharts-react-official-3.2.3.tgz",
|
||||
"integrity": "sha512-2gL8bVGe6Pf75tSe6IB5Ucd0nIOJX7ZrpttQBZVrjN2J9StdVZ12aOinXHIOFvlc6EzP8CbN13WS8NUq+9mptA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"highcharts": ">=6.0.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "werte_next",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"highcharts": "^12.5.0",
|
||||
"highcharts-react-official": "^3.2.3",
|
||||
"jose": "^6.1.3",
|
||||
"mysql2": "^3.17.4",
|
||||
"next": "16.1.6",
|
||||
|
||||
Reference in New Issue
Block a user