V 2.0.0 ferige Version
14
.dockerignore
Normal 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
@@ -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
|
||||
|
||||
25
Dockerfile
Normal 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
@@ -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);
|
||||
}
|
||||
235
app/components/SpritzClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
88
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<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 (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
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>
|
||||
<Suspense fallback={<div style={{ padding: '30px' }}>Lade…</div>}>
|
||||
<SpritzClient sysParams={sysParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
67
deploy.sh
Executable 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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
147
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
2
public/browserconfig.xml
Normal 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
|
After Width: | Height: | Size: 179 B |
BIN
public/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
41
public/manifest.json
Normal 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
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
184
scripts/migrate-mongo-to-mysql.mjs
Normal 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);
|
||||
});
|
||||