diff --git a/app/api/werte/route.ts b/app/api/werte/route.ts index 40c03b9..f143023 100644 --- a/app/api/werte/route.ts +++ b/app/api/werte/route.ts @@ -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 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); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + + 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 }, diff --git a/app/charts/page.tsx b/app/charts/page.tsx new file mode 100644 index 0000000..4778d16 --- /dev/null +++ b/app/charts/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +const ChartsClient = dynamic(() => import('@/components/ChartsClient'), { + ssr: false, + loading: () => ( +
+ Lade Grafiken… +
+ ), +}); + +export default function ChartsPage() { + return ; +} diff --git a/app/globals.css b/app/globals.css index c78599b..2c36089 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,7 @@ /* stylelint-disable at-rule-no-unknown */ @import "tailwindcss"; +@source "../components/**/*.tsx"; +@source "../app/**/*.tsx"; :root { --background: #ffffff; diff --git a/app/page.tsx b/app/page.tsx index f5a8597..eff9698 100644 --- a/app/page.tsx +++ b/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,7 +85,16 @@ export default function Home() {

Werte - Log

- +
+ + {'Verlauf ->'} + + +
diff --git a/components/ChartsClient.tsx b/components/ChartsClient.tsx new file mode 100644 index 0000000..ce4ae6a --- /dev/null +++ b/components/ChartsClient.tsx @@ -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 = { + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+

Werte – Verlauf

+
+ + {'<- Eingabe'} + + +
+
+ + {/* Date range picker */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + {error && {error}} + {!isLoading && !error && entries.length === 0 && ( + Keine Daten im gewählten Zeitraum. + )} +
+ + {/* Charts */} + {isLoading ? ( +
Lade Daten…
+ ) : ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index 032bf09..cecd2fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7ffa60b..a3798e4 100644 --- a/package.json +++ b/package.json @@ -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",