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 ( + + ); + })} +
+
+ + +
+
+
+ Version {sysParams.version} vom {sysParams.date} +
+
+
+ ); +} 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 ( -
-
- Next.js logo -
-

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