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

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 {
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;
}

View File

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

View File

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