V 2.0.0 ferige Version

This commit is contained in:
rxf
2026-03-16 09:20:30 +01:00
parent 244544492f
commit 889ed597ae
37 changed files with 1146 additions and 118 deletions

14
.dockerignore Normal file
View File

@@ -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

11
.gitignore vendored
View File

@@ -38,4 +38,15 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# scripts temp
scripts/*.bak
next-env.d.ts next-env.d.ts

25
Dockerfile Normal file
View File

@@ -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"]

23
app/api/data/route.ts Normal file
View File

@@ -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);
}

View File

@@ -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 (
<div className="inner">
{day.toFormat('d')}
<div className="lowline small">
<div className="wtg">{day.setLocale('de').toFormat('ccc')}</div>
<div className="eh">{einheit !== 0 ? einheit : ''}</div>
</div>
</div>
);
}
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<Schema | null>(null);
const [curEinheit, setCurEinheit] = useState<number>(sysParams.einheit);
const [monthsLabel, setMonthsLabel] = useState<string>('');
const [einheitInput, setEinheitInput] = useState<number>(sysParams.einheit);
const schemaRef = useRef<Schema | null>(null);
const curEinheitRef = useRef<number>(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<unknown> {
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<Schema> {
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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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 <div style={{ padding: '30px' }}>Lade</div>;
}
return (
<div className="spritztab">
<h1>Spritz-Tabelle</h1>
<h2 id="curmon">{monthsLabel}</h2>
<div id="sptab" onClick={handleClick}>
{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 (
<button
key={btId}
id={btId}
disabled={isDisabled}
aria-label={ariaLabel}
>
{day && <CellContent day={day} einheit={displayEinheit} />}
</button>
);
})}
</div>
<div id="infeld">
<label htmlFor="einheiten">Einheiten:</label>
<input
id="einheiten"
type="number"
min={0}
max={100}
value={einheitInput}
onChange={handleEinheitChange}
/>
</div>
<footer>
<div id="v">
Version {sysParams.version} vom {sysParams.date}
</div>
</footer>
</div>
);
}

View File

@@ -1,42 +1,103 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body { 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; margin: 0;
padding: 0;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
} }
a { a {
color: inherit; color: #00B7FF;
text-decoration: none;
} }
@media (prefers-color-scheme: dark) { .spritztab #sptab {
html { width: 91vmin;
color-scheme: dark; 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;
} }

View File

@@ -1,20 +1,33 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ export const viewport: Viewport = {
variable: "--font-geist-sans", width: 'device-width',
subsets: ["latin"], initialScale: 1,
}); };
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Spritzschema",
description: "Generated by create next app", 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({ export default function RootLayout({
@@ -23,10 +36,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="de">
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body>
{children}
</body>
</html> </html>
); );
} }

View File

@@ -1,66 +1,30 @@
import Image from "next/image"; import { Suspense } from 'react';
import styles from "./page.module.css"; 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<SearchParams>;
}) {
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 ( return (
<div className={styles.page}> <Suspense fallback={<div style={{ padding: '30px' }}>Lade</div>}>
<main className={styles.main}> <SpritzClient sysParams={sysParams} />
<Image </Suspense>
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className={styles.intro}>
<h1>To get started, edit the page.tsx file.</h1>
<p>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
); );
} }

67
deploy.sh Executable file
View File

@@ -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 ""

19
docker-compose.local.yml Normal file
View File

@@ -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

30
docker-compose.yml Normal file
View File

@@ -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:

190
lib/mysqlinterface.ts Normal file
View File

@@ -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<Result> {
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<Result> {
const erg: Result = { err: null };
try {
const [rows] = await conn.query<mysql.RowDataPacket[]>(
`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<Result> {
const erg: Result = { err: null };
try {
const [rows] = await conn.query<mysql.RowDataPacket[]>(
`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<Result> {
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<Result> {
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;
}

View File

@@ -1,7 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ turbopack: {
root: path.resolve(__dirname),
},
}; };
export default nextConfig; export default nextConfig;

147
package-lock.json generated
View File

@@ -8,11 +8,14 @@
"name": "spritzschema-next", "name": "spritzschema-next",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"luxon": "^3.7.2",
"mysql2": "^3.19.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.7.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -638,12 +641,19 @@
"tslib": "^2.8.0" "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": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -669,6 +679,15 @@
"@types/react": "^19.2.0" "@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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -714,6 +733,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -724,6 +752,101 @@
"node": ">=8" "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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -852,6 +975,12 @@
"react": "^19.2.3" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -925,6 +1054,21 @@
"node": ">=0.10.0" "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": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -972,7 +1116,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }

View File

@@ -1,18 +1,23 @@
{ {
"name": "spritzschema-next", "name": "spritzschema-next",
"version": "0.1.0", "version": "2.0.0",
"date": "2026-03-12",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "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": { "dependencies": {
"luxon": "^3.7.2",
"mysql2": "^3.19.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.7.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
public/apple-icon-57x57.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/apple-icon-60x60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/apple-icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/apple-icon-76x76.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

2
public/browserconfig.xml Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
public/favicon-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

41
public/manifest.json Normal file
View File

@@ -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"
}
]
}

BIN
public/ms-icon-144x144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
public/ms-icon-150x150.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
public/ms-icon-310x310.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/ms-icon-70x70.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -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);
});