diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..153236b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,14 @@
+.git
+.gitignore
+.next
+node_modules
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.DS_Store
+*.tsbuildinfo
+README.md
+scripts/
+.env*
+docker-compose.yml
+Dockerfile
diff --git a/.gitignore b/.gitignore
index 5ef6a52..62bf8df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,4 +38,15 @@ yarn-error.log*
# typescript
*.tsbuildinfo
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# scripts temp
+scripts/*.bak
next-env.d.ts
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3b9b8cb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM node:22-slim AS deps
+WORKDIR /app
+RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev
+
+FROM node:22-slim AS builder
+WORKDIR /app
+RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM node:22-slim AS runner
+WORKDIR /app
+RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*
+ENV NODE_ENV=production
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/next.config.ts ./next.config.ts
+EXPOSE 3000
+CMD ["npm", "start"]
diff --git a/app/api/data/route.ts b/app/api/data/route.ts
new file mode 100644
index 0000000..940acbc
--- /dev/null
+++ b/app/api/data/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { doMySQL } from '@/lib/mysqlinterface';
+
+export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const options = {
+ curdate: searchParams.get('curdate') ?? undefined,
+ testing: searchParams.get('test') ?? undefined,
+ };
+ const erg = await doMySQL('getlastdata', options);
+ return NextResponse.json(erg);
+}
+
+export async function POST(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const body = await request.json();
+ const options = {
+ data: body,
+ testing: searchParams.get('test') ?? undefined,
+ };
+ const erg = await doMySQL('putdata', options);
+ return NextResponse.json(erg);
+}
diff --git a/app/components/SpritzClient.tsx b/app/components/SpritzClient.tsx
new file mode 100644
index 0000000..57b0aae
--- /dev/null
+++ b/app/components/SpritzClient.tsx
@@ -0,0 +1,235 @@
+'use client';
+
+import { useEffect, useState, useCallback, useRef } from 'react';
+import { DateTime } from 'luxon';
+
+interface DataItem {
+ day: string;
+ status: boolean;
+ einheit: number;
+}
+
+interface Schema {
+ curdate: string;
+ months: string[];
+ years: string[];
+ data: DataItem[];
+ einheit: number;
+}
+
+export interface SysParams {
+ testing: boolean;
+ doinit: boolean;
+ einheit: number;
+ version: string;
+ date: string;
+}
+
+interface Props {
+ sysParams: SysParams;
+}
+
+function CellContent({ day, einheit }: { day: DateTime; einheit: number }) {
+ return (
+
+ {day.toFormat('d')}
+
+
{day.setLocale('de').toFormat('ccc')}
+
{einheit !== 0 ? einheit : ''}
+
+
+ );
+}
+
+function buildMonthsLabel(s: Schema): string {
+ let months = s.months.join(' - ');
+ months += ' ';
+ months += s.years.join('/');
+ return months;
+}
+
+export default function SpritzClient({ sysParams }: Props) {
+ const [schema, setSchema] = useState(null);
+ const [curEinheit, setCurEinheit] = useState(sysParams.einheit);
+ const [monthsLabel, setMonthsLabel] = useState('');
+ const [einheitInput, setEinheitInput] = useState(sysParams.einheit);
+
+ const schemaRef = useRef(null);
+ const curEinheitRef = useRef(sysParams.einheit);
+
+ schemaRef.current = schema;
+ curEinheitRef.current = curEinheit;
+
+ const apiUrl = sysParams.testing ? '/api/data?test=true' : '/api/data';
+
+ async function fetchData(): Promise<{ data: Schema | null; err: string | null }> {
+ const res = await fetch(apiUrl);
+ return res.json();
+ }
+
+ async function storeData(data: Schema): Promise {
+ const response = await fetch(apiUrl, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ return response.json();
+ }
+
+ async function initSchema(startdate: string): Promise {
+ const setArray: DataItem[] = [];
+ const monthArray: string[] = [];
+ const yearsArray: string[] = [];
+ const ld0 = DateTime.fromISO(startdate);
+ let k = 0;
+ for (let i = 0; i < 35; i++) {
+ const elem: DataItem = { status: false, einheit: 0, day: '' };
+ if (i === 17) {
+ elem.day = '';
+ } else {
+ const ld = ld0.plus({ day: k });
+ elem.day = ld.toFormat('y-LL-dd');
+ const month = ld.setLocale('de').toFormat('LLLL');
+ const year = ld.toFormat('y');
+ if (!monthArray.includes(month)) monthArray.push(month);
+ if (!yearsArray.includes(year)) yearsArray.push(year);
+ k++;
+ }
+ setArray.push(elem);
+ }
+ const newSchema: Schema = {
+ curdate: startdate,
+ months: monthArray,
+ years: yearsArray,
+ data: setArray,
+ einheit: curEinheitRef.current,
+ };
+ await storeData(newSchema);
+ return newSchema;
+ }
+
+ function applySchema(s: Schema) {
+ // Update einheit input to last non-zero einheit found in data
+ let lastEinheit = s.einheit;
+ for (const item of s.data) {
+ if (item.einheit !== 0) lastEinheit = item.einheit;
+ }
+ if (lastEinheit !== 0 && curEinheitRef.current === 0) {
+ setCurEinheit(lastEinheit);
+ curEinheitRef.current = lastEinheit;
+ setEinheitInput(lastEinheit);
+ }
+ setMonthsLabel(buildMonthsLabel(s));
+ setSchema(s);
+ }
+
+ useEffect(() => {
+ async function init() {
+ let s: Schema | null = null;
+ if (sysParams.doinit) {
+ s = await initSchema('2023-05-01');
+ }
+ if (!s) {
+ const ret = await fetchData();
+ s = ret.data;
+ }
+ if (!s) {
+ // DB leer → neues Schema mit aktuellem Datum anlegen
+ const today = DateTime.now().toFormat('y-LL-dd');
+ s = await initSchema(today);
+ }
+ if (s) {
+ applySchema(s);
+ }
+ }
+ init();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleClick = useCallback(async (e: React.MouseEvent) => {
+ const button = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null;
+ if (!button || button.disabled) return;
+ const currentSchema = schemaRef.current;
+ if (!currentSchema) return;
+
+ const idNum = parseInt(button.id.slice(2)) - 1; // 0-basierter Index
+ const lastDay = currentSchema.data[34].day;
+
+ const newSchema: Schema = {
+ ...currentSchema,
+ data: currentSchema.data.map((item, idx) => {
+ if (idx === idNum) {
+ return { ...item, status: !item.status, einheit: curEinheitRef.current };
+ }
+ return item;
+ }),
+ };
+
+ await storeData(newSchema);
+ applySchema(newSchema);
+
+ // Letzten Button (bt35) geklickt → nächste Periode anlegen
+ if (button.id === 'bt35') {
+ const nextDate = DateTime.fromISO(lastDay).plus({ day: 1 }).toFormat('y-LL-dd');
+ const nextSchema = await initSchema(nextDate);
+ applySchema(nextSchema);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleEinheitChange = useCallback((e: React.ChangeEvent) => {
+ const val = parseInt(e.target.value) || 0;
+ setCurEinheit(val);
+ curEinheitRef.current = val;
+ setEinheitInput(val);
+ if (schemaRef.current) {
+ schemaRef.current = { ...schemaRef.current, einheit: val };
+ }
+ }, []);
+
+ if (!schema) {
+ return Lade…
;
+ }
+
+ return (
+
+
Spritz-Tabelle
+
{monthsLabel}
+
+ {schema.data.map((item, idx) => {
+ const btId = `bt${idx + 1}`;
+ const day = item.day ? DateTime.fromISO(item.day) : null;
+ const isDisabled = item.day === '';
+ const ariaLabel = isDisabled ? 'o' : item.status ? 'x' : '';
+ const displayEinheit = item.einheit !== 0 ? item.einheit : (item.status ? schema.einheit : 0);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/globals.css b/app/globals.css
index e3734be..e66895c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,42 +1,103 @@
-:root {
- --background: #ffffff;
- --foreground: #171717;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
-
-html,
body {
- max-width: 100vw;
- overflow-x: hidden;
-}
-
-body {
- color: var(--foreground);
- background: var(--background);
- font-family: Arial, Helvetica, sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-* {
- box-sizing: border-box;
- padding: 0;
margin: 0;
+ padding: 0;
+ font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
- color: inherit;
- text-decoration: none;
+ color: #00B7FF;
}
-@media (prefers-color-scheme: dark) {
- html {
- color-scheme: dark;
- }
+.spritztab #sptab {
+ width: 91vmin;
+ height: 65vmin;
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 0;
+ margin: 3vmin auto;
+}
+
+.spritztab h1 {
+ text-align: center;
+ margin-top: 3vmin;
+ font-size: 8vmin;
+}
+
+.spritztab h2 {
+ text-align: center;
+ font-size: 5vmin;
+}
+
+.spritztab button {
+ width: 13vmin;
+ height: 13vmin;
+ background: white;
+ border: 1px solid black;
+ margin: 0;
+ font-size: 3.5vmin;
+ color: black;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 0.5vmin 0.5vmin 0;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.spritztab [aria-label="o"] {
+ background-image: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201%201%22%3E%3Ccircle%20cx%3D%220.5%22%20cy%3D%220.5%22%20r%3D%220.4%22%20fill%3D%22none%22%20stroke-width%3D%220.1%22%20stroke%3D%22blue%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.spritztab [aria-label="x"] {
+ background-image: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%201%201%22%3E%3Cline%20x1%3D%220.1%22%20y1%3D%220.1%22%20x2%3D%220.9%22%20y2%3D%220.9%22%20stroke-width%3D%220.1%22%20stroke%3D%22red%22%2F%3E%3Cline%20x1%3D%220.1%22%20y1%3D%220.9%22%20x2%3D%220.9%22%20y2%3D%220.1%22%20stroke-width%3D%220.1%22%20stroke%3D%22red%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.small {
+ font-size: 60%;
+ color: gray;
+}
+
+#infeld {
+ width: 91vmin;
+ margin: auto;
+ font-size: 3.5vmin;
+}
+
+#einheiten {
+ font-size: 3.5vmin;
+ margin-left: 2vmin;
+}
+
+footer {
+ width: 91vmin;
+ margin: auto;
+ font-size: 2vmin;
+}
+#v {
+ text-align: right;
+}
+
+
+
+
+
+
+.inner {
+ width: 100%;
+ line-height: 1.1;
+}
+
+.lowline {
+ width: 100%;
+ clear: both;
+ margin-top: 0.5vmin;
+}
+
+.eh {
+ color: black;
+ float: right;
+}
+
+.wtg {
+ float: left;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 42fc323..7a9b840 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,20 +1,33 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { Metadata, Viewport } from "next";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+export const viewport: Viewport = {
+ width: 'device-width',
+ initialScale: 1,
+};
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Spritzschema",
+ manifest: "/manifest.json",
+ icons: {
+ apple: [
+ { url: "/apple-icon-57x57.png", sizes: "57x57" },
+ { url: "/apple-icon-60x60.png", sizes: "60x60" },
+ { url: "/apple-icon-72x72.png", sizes: "72x72" },
+ { url: "/apple-icon-76x76.png", sizes: "76x76" },
+ { url: "/apple-icon-114x114.png", sizes: "114x114" },
+ { url: "/apple-icon-120x120.png", sizes: "120x120" },
+ { url: "/apple-icon-144x144.png", sizes: "144x144" },
+ { url: "/apple-icon-152x152.png", sizes: "152x152" },
+ { url: "/apple-icon-180x180.png", sizes: "180x180" },
+ ],
+ icon: [
+ { url: "/android-icon-192x192.png", sizes: "192x192", type: "image/png" },
+ { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ ],
+ },
};
export default function RootLayout({
@@ -23,10 +36,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
-
+
+ {children}
);
}
diff --git a/app/page.tsx b/app/page.tsx
index 7b947a2..f757f49 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,66 +1,30 @@
-import Image from "next/image";
-import styles from "./page.module.css";
+import { Suspense } from 'react';
+import SpritzClient from './components/SpritzClient';
+import type { SysParams } from './components/SpritzClient';
+import pkg from '../package.json';
-export default function Home() {
+interface SearchParams {
+ test?: string;
+ doinit?: string;
+ einheit?: string;
+}
+
+export default async function Page({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const sysParams: SysParams = {
+ testing: params.test === 'true',
+ doinit: params.doinit === 'true',
+ einheit: parseInt(params.einheit ?? '0') || 0,
+ version: pkg.version,
+ date: (pkg as typeof pkg & { date?: string }).date ?? '',
+ };
return (
-
-
-
-
-
To get started, edit the page.tsx file.
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
+ Lade…}>
+
+
);
}
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..1a56dd5
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+
+# Deploy Script für laufschrift
+# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
+
+set -e
+
+
+# Konfiguration
+REGISTRY="docker.citysensor.de"
+IMAGE_NAME="spritzschema"
+TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
+FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
+
+# Build-Datum
+BUILD_DATE=$(date +%d.%m.%Y)
+
+echo "=========================================="
+echo " Deploy Script"
+echo "=========================================="
+echo "Registry: ${REGISTRY}"
+echo "Image: ${IMAGE_NAME}"
+echo "Tag: ${TAG}"
+echo "Build-Datum: ${BUILD_DATE}"
+echo "=========================================="
+echo ""
+
+# 1. Login zur Registry (falls noch nicht eingeloggt)
+echo ">>> Login zu ${REGISTRY}..."
+docker login "${REGISTRY}"
+echo ""
+
+# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
+echo ">>> Richte Multiplatform Builder ein..."
+if ! docker buildx inspect multiplatform-builder &>/dev/null; then
+ docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
+fi
+docker buildx use multiplatform-builder
+echo ""
+
+# 3. Docker Image bauen und pushen (Multiplatform)
+echo ">>> Baue Multiplatform Docker Image und pushe zu Registry..."
+docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --build-arg BUILD_DATE="${BUILD_DATE}" \
+ -t "${FULL_IMAGE}" \
+ --push \
+ .
+
+# 4. Keep :latest in sync for simple rollbacks and manual usage.
+echo ">>> Tagge das image zusätzlich als :latest ..."
+docker buildx imagetools create \
+ -t "${REGISTRY}/${IMAGE_NAME}:latest" \
+ "${FULL_IMAGE}"
+
+
+echo ">>> Build und Push erfolgreich!"
+
+echo ""
+echo "=========================================="
+echo "✓ Deploy erfolgreich abgeschlossen!"
+echo "=========================================="
+echo ""
+echo "Auf dem Server ausführen:"
+echo " docker pull ${FULL_IMAGE}"
+echo " docker-compose -f docker-compose.prod.yml up -d"
+echo ""
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000..32c7710
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,19 @@
+# docker-compose.local.yml
+# Lokaler Betrieb – MySQL läuft bereits auf dem Host (localhost)
+#
+# Start:
+# docker compose -f docker-compose.local.yml up --build
+
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ environment:
+ # MySQL – DB_HOST überschreibt localhost aus .env.local
+ DB_HOST: host.docker.internal
+ env_file:
+ - .env.local
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..6c15692
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+services:
+ app:
+ build: .
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ env_file:
+ - .env.local
+ environment:
+ MYSQLHOST: db
+ depends_on:
+ db:
+ condition: service_healthy
+
+ db:
+ image: mysql:8.4
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQLPASSWORD}
+ MYSQL_DATABASE: ${MYSQLBASE}
+ volumes:
+ - mysql_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQLPASSWORD}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ mysql_data:
diff --git a/lib/mysqlinterface.ts b/lib/mysqlinterface.ts
new file mode 100644
index 0000000..31ecb50
--- /dev/null
+++ b/lib/mysqlinterface.ts
@@ -0,0 +1,190 @@
+import mysql from 'mysql2/promise';
+
+const DB_HOST = process.env.DB_HOST || 'localhost';
+const DB_PORT = parseInt(process.env.DB_PORT || '3306');
+const DB_USER = process.env.DB_USER || 'root';
+const DB_PASS = process.env.DB_PASS || '';
+const DB_BASE = 'medizin';
+
+// Singleton pool – überlebt Hot-Reloads im Dev-Modus
+declare global {
+ // eslint-disable-next-line no-var
+ var _mysqlPool: mysql.Pool | undefined;
+}
+
+function getPool(): mysql.Pool {
+ if (!global._mysqlPool) {
+ global._mysqlPool = mysql.createPool({
+ host: DB_HOST,
+ port: DB_PORT,
+ user: DB_USER,
+ password: DB_PASS,
+ database: DB_BASE,
+ waitForConnections: true,
+ connectionLimit: 10,
+ });
+ }
+ return global._mysqlPool;
+}
+
+async function ensureTable(conn: mysql.PoolConnection, tableName: string) {
+ await conn.query(`
+ CREATE TABLE IF NOT EXISTS \`${tableName}\` (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ curdate VARCHAR(20) NOT NULL UNIQUE,
+ months JSON NOT NULL,
+ years JSON NOT NULL,
+ data JSON NOT NULL,
+ einheit INT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ )
+ `);
+}
+
+export interface DataItem {
+ day: string;
+ status: boolean;
+ einheit: number;
+}
+
+export interface Schema {
+ curdate: string;
+ months: string[];
+ years: string[];
+ data: DataItem[];
+ einheit: number;
+}
+
+interface Options {
+ testing?: string | boolean;
+ curdate?: string;
+ data?: Schema;
+}
+
+interface Result {
+ err: null | string;
+ data?: Schema | null;
+ date?: unknown;
+}
+
+export async function doMySQL(cmd: string, options: Options): Promise {
+ const erg: Result = { err: null };
+ const tableName = options.testing ? 'spritzschema_test' : 'spritzschema';
+ const pool = getPool();
+ const conn = await pool.getConnection();
+ try {
+ await ensureTable(conn, tableName);
+ if (cmd === 'getlastdata') {
+ return await getLastData(conn, tableName);
+ } else if (cmd === 'putdata') {
+ return await putData(conn, tableName, options);
+ } else if (cmd === 'getdata') {
+ return await getData(conn, tableName, options);
+ } else if (cmd === 'deldata') {
+ return await delData(conn, tableName, options);
+ } else {
+ erg.err = 'Unknown Call';
+ }
+ } catch (e) {
+ console.error(e);
+ erg.err = String(e);
+ } finally {
+ conn.release();
+ }
+ return erg;
+}
+
+async function getLastData(conn: mysql.PoolConnection, tableName: string): Promise {
+ const erg: Result = { err: null };
+ try {
+ const [rows] = await conn.query(
+ `SELECT curdate, months, years, data, einheit FROM \`${tableName}\` ORDER BY id DESC LIMIT 1`
+ );
+ if (rows.length > 0) {
+ const row = rows[0];
+ erg.data = {
+ curdate: row.curdate,
+ months: typeof row.months === 'string' ? JSON.parse(row.months) : row.months,
+ years: typeof row.years === 'string' ? JSON.parse(row.years) : row.years,
+ data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
+ einheit: row.einheit,
+ };
+ } else {
+ erg.data = null;
+ }
+ } catch (e) {
+ console.error(e);
+ erg.err = String(e);
+ }
+ return erg;
+}
+
+async function getData(conn: mysql.PoolConnection, tableName: string, options: Options): Promise {
+ const erg: Result = { err: null };
+ try {
+ const [rows] = await conn.query(
+ `SELECT curdate, months, years, data, einheit FROM \`${tableName}\` WHERE curdate = ?`,
+ [options.curdate]
+ );
+ if (rows.length > 0) {
+ const row = rows[0];
+ erg.data = {
+ curdate: row.curdate,
+ months: typeof row.months === 'string' ? JSON.parse(row.months) : row.months,
+ years: typeof row.years === 'string' ? JSON.parse(row.years) : row.years,
+ data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
+ einheit: row.einheit,
+ };
+ } else {
+ erg.data = null;
+ }
+ } catch (e) {
+ console.error(e);
+ erg.err = String(e);
+ }
+ return erg;
+}
+
+async function putData(conn: mysql.PoolConnection, tableName: string, options: Options): Promise {
+ const erg: Result = { err: null, date: null };
+ const schema = options.data!;
+ try {
+ const [result] = await conn.query(
+ `INSERT INTO \`${tableName}\` (curdate, months, years, data, einheit)
+ VALUES (?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ months = VALUES(months),
+ years = VALUES(years),
+ data = VALUES(data),
+ einheit = VALUES(einheit),
+ updated_at = CURRENT_TIMESTAMP`,
+ [
+ schema.curdate,
+ JSON.stringify(schema.months),
+ JSON.stringify(schema.years),
+ JSON.stringify(schema.data),
+ schema.einheit,
+ ]
+ );
+ erg.date = result;
+ } catch (e) {
+ console.error(e);
+ erg.err = String(e);
+ }
+ return erg;
+}
+
+async function delData(conn: mysql.PoolConnection, tableName: string, options: Options): Promise {
+ const erg: Result = { err: null };
+ try {
+ const [result] = await conn.query(
+ `DELETE FROM \`${tableName}\` WHERE curdate = ?`,
+ [options.curdate]
+ );
+ erg.data = result as unknown as Schema;
+ } catch (e) {
+ console.error(e);
+ erg.err = String(e);
+ }
+ return erg;
+}
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..b060a82 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
+import path from "path";
const nextConfig: NextConfig = {
- /* config options here */
+ turbopack: {
+ root: path.resolve(__dirname),
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index d921086..3931898 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,11 +8,14 @@
"name": "spritzschema-next",
"version": "0.1.0",
"dependencies": {
+ "luxon": "^3.7.2",
+ "mysql2": "^3.19.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
+ "@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -638,12 +641,19 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@types/luxon": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
+ "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
- "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -669,6 +679,15 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -714,6 +733,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -724,6 +752,101 @@
"node": ">=8"
}
},
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru.min": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+ "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/mysql2": {
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz",
+ "integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.2",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.2",
+ "long": "^5.3.2",
+ "lru.min": "^1.1.4",
+ "named-placeholders": "^1.1.6",
+ "sql-escaper": "^1.3.3"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 8"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
+ "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru.min": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -852,6 +975,12 @@
"react": "^19.2.3"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -925,6 +1054,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sql-escaper": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
+ "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=2.0.0",
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -972,7 +1116,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
}
}
diff --git a/package.json b/package.json
index a91de06..0c22512 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,23 @@
{
"name": "spritzschema-next",
- "version": "0.1.0",
+ "version": "2.0.0",
+ "date": "2026-03-12",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
- "start": "next start"
+ "start": "next start",
+ "docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t docker.citysensor.de/spritzschema-next:latest --push ."
},
"dependencies": {
+ "luxon": "^3.7.2",
+ "mysql2": "^3.19.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
+ "@types/luxon": "^3.7.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/public/android-icon-192x192.png b/public/android-icon-192x192.png
new file mode 100644
index 0000000..658a6fc
Binary files /dev/null and b/public/android-icon-192x192.png differ
diff --git a/public/apple-icon-114x114.png b/public/apple-icon-114x114.png
new file mode 100644
index 0000000..1cd9185
Binary files /dev/null and b/public/apple-icon-114x114.png differ
diff --git a/public/apple-icon-120x120.png b/public/apple-icon-120x120.png
new file mode 100644
index 0000000..4fdc9e9
Binary files /dev/null and b/public/apple-icon-120x120.png differ
diff --git a/public/apple-icon-144x144.png b/public/apple-icon-144x144.png
new file mode 100644
index 0000000..238d867
Binary files /dev/null and b/public/apple-icon-144x144.png differ
diff --git a/public/apple-icon-152x152.png b/public/apple-icon-152x152.png
new file mode 100644
index 0000000..893df06
Binary files /dev/null and b/public/apple-icon-152x152.png differ
diff --git a/public/apple-icon-180x180.png b/public/apple-icon-180x180.png
new file mode 100644
index 0000000..5018c63
Binary files /dev/null and b/public/apple-icon-180x180.png differ
diff --git a/public/apple-icon-57x57.png b/public/apple-icon-57x57.png
new file mode 100644
index 0000000..5708bfe
Binary files /dev/null and b/public/apple-icon-57x57.png differ
diff --git a/public/apple-icon-60x60.png b/public/apple-icon-60x60.png
new file mode 100644
index 0000000..4458f1d
Binary files /dev/null and b/public/apple-icon-60x60.png differ
diff --git a/public/apple-icon-72x72.png b/public/apple-icon-72x72.png
new file mode 100644
index 0000000..d6db417
Binary files /dev/null and b/public/apple-icon-72x72.png differ
diff --git a/public/apple-icon-76x76.png b/public/apple-icon-76x76.png
new file mode 100644
index 0000000..9c94bfe
Binary files /dev/null and b/public/apple-icon-76x76.png differ
diff --git a/public/browserconfig.xml b/public/browserconfig.xml
new file mode 100644
index 0000000..c554148
--- /dev/null
+++ b/public/browserconfig.xml
@@ -0,0 +1,2 @@
+
+#ffffff
\ No newline at end of file
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
new file mode 100644
index 0000000..4e44701
Binary files /dev/null and b/public/favicon-16x16.png differ
diff --git a/public/favicon-256x256.png b/public/favicon-256x256.png
new file mode 100644
index 0000000..0955e1d
Binary files /dev/null and b/public/favicon-256x256.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
new file mode 100644
index 0000000..e5b8ab2
Binary files /dev/null and b/public/favicon-32x32.png differ
diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png
new file mode 100644
index 0000000..b14c7ea
Binary files /dev/null and b/public/favicon-96x96.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..588c5e9
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..013d4a6
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,41 @@
+{
+ "name": "App",
+ "icons": [
+ {
+ "src": "\/android-icon-36x36.png",
+ "sizes": "36x36",
+ "type": "image\/png",
+ "density": "0.75"
+ },
+ {
+ "src": "\/android-icon-48x48.png",
+ "sizes": "48x48",
+ "type": "image\/png",
+ "density": "1.0"
+ },
+ {
+ "src": "\/android-icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image\/png",
+ "density": "1.5"
+ },
+ {
+ "src": "\/android-icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image\/png",
+ "density": "2.0"
+ },
+ {
+ "src": "\/android-icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image\/png",
+ "density": "3.0"
+ },
+ {
+ "src": "\/android-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image\/png",
+ "density": "4.0"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/ms-icon-144x144.png b/public/ms-icon-144x144.png
new file mode 100644
index 0000000..238d867
Binary files /dev/null and b/public/ms-icon-144x144.png differ
diff --git a/public/ms-icon-150x150.png b/public/ms-icon-150x150.png
new file mode 100644
index 0000000..a1085fe
Binary files /dev/null and b/public/ms-icon-150x150.png differ
diff --git a/public/ms-icon-310x310.png b/public/ms-icon-310x310.png
new file mode 100644
index 0000000..b8f89bd
Binary files /dev/null and b/public/ms-icon-310x310.png differ
diff --git a/public/ms-icon-70x70.png b/public/ms-icon-70x70.png
new file mode 100644
index 0000000..f98d8ac
Binary files /dev/null and b/public/ms-icon-70x70.png differ
diff --git a/scripts/migrate-mongo-to-mysql.mjs b/scripts/migrate-mongo-to-mysql.mjs
new file mode 100644
index 0000000..35180ba
--- /dev/null
+++ b/scripts/migrate-mongo-to-mysql.mjs
@@ -0,0 +1,184 @@
+/**
+ * MongoDB-Export → MySQL Migration
+ *
+ * Liest eine NDJSON-Datei (mongoexport-Format, eine Zeile = ein Dokument)
+ * und importiert alle Einträge in die MySQL-Tabelle.
+ *
+ * Konfiguration via Umgebungsvariablen oder direkt hier eintragen:
+ *
+ * IMPORT_FILE Pfad zur Exportdatei (default: ~/Downloads/spritzschema_a)
+ * MYSQLHOST MySQL-Host (default: localhost)
+ * MYSQLPORT MySQL-Port (default: 3306)
+ * MYSQLUSER MySQL-Benutzer (default: root)
+ * MYSQLPASSWORD MySQL-Passwort (default: leer)
+ * MYSQLBASE MySQL-Datenbank (default: medizin)
+ * MYSQL_TABLE MySQL-Tabellenname (default: spritzschema)
+ *
+ * Aufruf:
+ * node scripts/migrate-mongo-to-mysql.mjs
+ * IMPORT_FILE=/pfad/zur/datei node scripts/migrate-mongo-to-mysql.mjs
+ */
+
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import mysql from 'mysql2/promise';
+
+// ── .env.local laden (falls vorhanden) ────────────────────────
+const envFile = path.resolve(process.cwd(), '.env.local');
+if (fs.existsSync(envFile)) {
+ for (const line of fs.readFileSync(envFile, 'utf-8').split('\n')) {
+ const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+ }
+}
+
+// ── Konfiguration ──────────────────────────────────────────────
+const IMPORT_FILE = process.env.IMPORT_FILE
+ || path.join(os.homedir(), 'Downloads', 'spritzschema_a');
+
+const MYSQLHOST = process.env.MYSQLHOST || 'localhost';
+const MYSQLPORT = parseInt(process.env.MYSQLPORT || '3306');
+const MYSQLUSER = process.env.MYSQLUSER || 'root';
+const MYSQLPASSWORD = process.env.MYSQLPASSWORD || '';
+const MYSQLBASE = process.env.MYSQLBASE || 'medizin';
+const MYSQL_TABLE = process.env.MYSQL_TABLE || 'spritzschema';
+// ───────────────────────────────────────────────────────────────
+
+async function ensureTable(conn) {
+ await conn.query(`
+ CREATE TABLE IF NOT EXISTS \`${MYSQL_TABLE}\` (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ curdate VARCHAR(20) NOT NULL UNIQUE,
+ months JSON NOT NULL,
+ years JSON NOT NULL,
+ data JSON NOT NULL,
+ einheit INT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ )
+ `);
+}
+
+function normalizeDoc(doc) {
+ // einheit in data-Items kann String oder Number sein → immer Int
+ const data = (Array.isArray(doc.data) ? doc.data : []).map((item) => ({
+ ...item,
+ einheit: parseInt(item.einheit) || 0,
+ }));
+ return {
+ curdate: doc.curdate ?? '',
+ months: Array.isArray(doc.months) ? doc.months : [],
+ years: Array.isArray(doc.years) ? doc.years : [],
+ data,
+ einheit: parseInt(doc.einheit) || 0,
+ };
+}
+
+async function readExportFile(filePath) {
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
+ // Unterstützt beide Formate: JSON-Array [...] und NDJSON (eine Zeile = ein Dokument)
+ if (content.startsWith('[')) {
+ return JSON.parse(content);
+ }
+ // NDJSON-Fallback
+ const docs = [];
+ let lineNo = 0;
+ for (const line of content.split('\n')) {
+ lineNo++;
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ try {
+ docs.push(JSON.parse(trimmed));
+ } catch (e) {
+ console.warn(` SKIP Zeile ${lineNo}: Kein gültiges JSON (${e.message})`);
+ }
+ }
+ return docs;
+}
+
+async function migrate() {
+ // ── Exportdatei lesen ──
+ console.log(`Lese Exportdatei: ${IMPORT_FILE}`);
+ if (!fs.existsSync(IMPORT_FILE)) {
+ console.error(`FEHLER: Datei nicht gefunden: ${IMPORT_FILE}`);
+ process.exit(1);
+ }
+ const docs = await readExportFile(IMPORT_FILE);
+ console.log(` ${docs.length} Dokument(e) eingelesen.`);
+
+ if (docs.length === 0) {
+ console.log('Nichts zu migrieren.');
+ return;
+ }
+
+ // ── MySQL verbinden ──
+ console.log(`MySQL verbinden: ${MYSQLHOST}:${MYSQLPORT} / ${MYSQLBASE}`);
+ const conn = await mysql.createConnection({
+ host: MYSQLHOST,
+ port: MYSQLPORT,
+ user: MYSQLUSER,
+ password: MYSQLPASSWORD,
+ database: MYSQLBASE,
+ });
+
+ await ensureTable(conn);
+ console.log(` Tabelle '${MYSQL_TABLE}' bereit.`);
+
+ let inserted = 0;
+ let updated = 0;
+ let errors = 0;
+
+ for (const doc of docs) {
+ const row = normalizeDoc(doc);
+
+ if (!row.curdate) {
+ console.warn(` SKIP: Dokument ohne curdate (_id=${doc._id})`);
+ errors++;
+ continue;
+ }
+
+ try {
+ const [result] = await conn.query(
+ `INSERT INTO \`${MYSQL_TABLE}\` (curdate, months, years, data, einheit)
+ VALUES (?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ months = VALUES(months),
+ years = VALUES(years),
+ data = VALUES(data),
+ einheit = VALUES(einheit),
+ updated_at = CURRENT_TIMESTAMP`,
+ [
+ row.curdate,
+ JSON.stringify(row.months),
+ JSON.stringify(row.years),
+ JSON.stringify(row.data),
+ row.einheit,
+ ]
+ );
+ // affectedRows=1 → INSERT, affectedRows=2 → UPDATE (ON DUPLICATE KEY)
+ if (result.affectedRows === 1) {
+ console.log(` INSERT curdate=${row.curdate}`);
+ inserted++;
+ } else {
+ console.log(` UPDATE curdate=${row.curdate}`);
+ updated++;
+ }
+ } catch (e) {
+ console.error(` FEHLER curdate=${row.curdate}: ${e.message}`);
+ errors++;
+ }
+ }
+
+ await conn.end();
+
+ console.log('\n── Ergebnis ──────────────────────────');
+ console.log(` Inserted : ${inserted}`);
+ console.log(` Updated : ${updated}`);
+ console.log(` Errors : ${errors}`);
+ console.log(` Gesamt : ${docs.length}`);
+}
+
+migrate().catch((err) => {
+ console.error('Fataler Fehler:', err);
+ process.exit(1);
+});