chore: Projektgerüst und Werkzeugkette einrichten
Next 16 mit App Router und standalone-Ausgabe, React 19, TypeScript strict, Tailwind 4, Vitest und Playwright. Alle Versionen exakt gepinnt. Abweichungen vom ursprünglich notierten Stand, jeweils begründet: - Prisma 7.10.0 statt 6, weil 6 überholt ist und 8 nur als RC vorliegt. - TypeScript 6.0.3 statt 7, weil typescript-eslint 8 nur "<6.1.0" trägt. - ESLint 9.39.5 statt 10, weil das in eslint-config-next gebündelte eslint-plugin-react noch context.getFilename() benutzt. - npm-overrides für SimpleWebAuthn und nodemailer, weil next-auth dort ältere optionale Peers deklariert. Die betroffenen next-auth-Provider werden nicht benutzt: Passkeys laufen direkt über SimpleWebAuthn 14. "npm run pruefen" bündelt Prettier, ESLint, tsc, Vitest und ShellCheck. ShellCheck läuft über das Docker-Abbild, wenn es lokal fehlt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8220adc449
commit
de1f923767
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
logs
|
||||
sicherungen
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
docs/bilder
|
||||
devlog
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,62 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planer – Beispielkonfiguration
|
||||
# Kopieren nach .env und ausfuellen. .env gehoert NIE ins Git.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Welche Instanz laeuft hier? Steuert Provider, Integrationen und Routen.
|
||||
# Erlaubt: firma | daheim
|
||||
INSTANZ=daheim
|
||||
|
||||
# Oeffentliche Adresse der Anwendung, ohne abschliessenden Schraegstrich.
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# --- Datenbank -------------------------------------------------------------
|
||||
# Muss zu den POSTGRES_*-Werten unten passen, wenn Docker Compose benutzt wird.
|
||||
DATABASE_URL=postgresql://planer:bitte-aendern@localhost:5432/planer?schema=public
|
||||
|
||||
# Nur fuer docker compose: Zugangsdaten des Postgres-Containers.
|
||||
POSTGRES_USER=planer
|
||||
POSTGRES_PASSWORD=bitte-aendern
|
||||
POSTGRES_DB=planer
|
||||
|
||||
# Nach aussen veroeffentlichte Ports. Aendern, wenn 5432 oder 3000 auf dem
|
||||
# Rechner schon belegt sind; DATABASE_URL muss dann denselben Port nennen.
|
||||
POSTGRES_PORT=5432
|
||||
APP_PORT=3000
|
||||
|
||||
# --- Anmeldung -------------------------------------------------------------
|
||||
# Signiert Sitzungen und Tokens. Erzeugen mit:
|
||||
# openssl rand -base64 32
|
||||
AUTH_SECRET=
|
||||
|
||||
# Hinter einem Reverse Proxy auf true setzen, damit Auth.js dem Host vertraut.
|
||||
AUTH_TRUST_HOST=true
|
||||
|
||||
# Anmeldung per E-Mail und Passwort erlauben. In der Firma ueblicherweise
|
||||
# false, damit nur ueber OIDC angemeldet wird.
|
||||
ANMELDUNG_PASSWORT=true
|
||||
|
||||
# --- OIDC ------------------------------------------------------------------
|
||||
# firma = Entra ID, daheim = authentik. Leer lassen schaltet OIDC ab.
|
||||
# Der Rueckleit-URI beim Anbieter lautet:
|
||||
# <APP_URL>/api/auth/callback/oidc
|
||||
OIDC_NAME=
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# --- Passkeys (WebAuthn) ---------------------------------------------------
|
||||
# RP_ID ist die nackte Domain ohne Schema und Port, z. B. planer.example.de.
|
||||
# Lokal ist "localhost" richtig.
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_RP_NAME=Planer
|
||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||
|
||||
# --- Sonstiges -------------------------------------------------------------
|
||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Nur fuer die Screenshot-Instanz. Schaltet den Demo-Kalenderprovider frei
|
||||
# und darf in echten Umgebungen niemals true sein.
|
||||
DEMO_MODUS=false
|
||||
@@ -0,0 +1,8 @@
|
||||
# Im Git immer LF, damit die Shellskripte auch unter Linux laufen.
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.woff2 binary
|
||||
package-lock.json -diff
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Abhängigkeiten
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# Erzeugter Prisma-Client
|
||||
src/generated/
|
||||
|
||||
# Tests
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.playwright/
|
||||
|
||||
# Geheimnisse und lokale Umgebung
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Betrieb
|
||||
logs/
|
||||
sicherungen/
|
||||
*.dump
|
||||
|
||||
# Zeiterfassung: laufender Eintrag ist lokal
|
||||
devlog/.zeit-offen
|
||||
|
||||
# Werkzeuge
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
@@ -0,0 +1,10 @@
|
||||
.next
|
||||
node_modules
|
||||
package-lock.json
|
||||
docs/bilder
|
||||
prisma/migrations
|
||||
logs
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
devlog/zeiten.csv
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: planer-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-planer}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-planer}
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
volumes:
|
||||
- db-daten:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '${POSTGRES_PORT:-5432}:5432'
|
||||
healthcheck:
|
||||
test:
|
||||
['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planer} -d ${POSTGRES_DB:-planer}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: planer-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
INSTANZ: ${INSTANZ:?INSTANZ fehlt in .env}
|
||||
APP_URL: ${APP_URL:?APP_URL fehlt in .env}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
|
||||
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in .env}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||
ANMELDUNG_PASSWORT: ${ANMELDUNG_PASSWORT:-false}
|
||||
OIDC_NAME: ${OIDC_NAME:-}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
|
||||
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
|
||||
DEMO_MODUS: 'false'
|
||||
ports:
|
||||
- '${APP_PORT:-3000}:3000'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
db-daten:
|
||||
@@ -0,0 +1,57 @@
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import naechstesWebVitals from 'eslint-config-next/core-web-vitals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
const wurzel = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'.next/**',
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||
'src/generated/**',
|
||||
'next-env.d.ts',
|
||||
],
|
||||
},
|
||||
...naechstesWebVitals,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: wurzel,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
// Keine Geheimnisse in Logs: console nur fuer Warnungen und Fehler.
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
},
|
||||
},
|
||||
// Skripte, Seed und Tests duerfen auf die Konsole schreiben.
|
||||
{
|
||||
files: ['scripts/**', 'prisma/**', '**/*.test.ts', '**/*.test.tsx', 'e2e/**'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.mjs', '**/*.cjs'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
typedRoutes: true,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
Generated
+12607
File diff suppressed because it is too large
Load Diff
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"pruefen": "node scripts/pruefen.mjs",
|
||||
"format": "prettier --write .",
|
||||
"format:pruefen": "prettier --check .",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"typen": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:beobachten": "vitest",
|
||||
"test:integration": "tsx scripts/integrationstest.ts",
|
||||
"test:migration": "tsx scripts/migrationstest.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"db:generieren": "prisma generate",
|
||||
"db:migrieren": "prisma migrate dev",
|
||||
"db:einspielen": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"overrides": {
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"nodemailer": "10.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"bcryptjs": "3.0.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"lucide-react": "1.44.0",
|
||||
"next": "16.3.4",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-themes": "0.4.6",
|
||||
"node-cron": "4.6.0",
|
||||
"nodemailer": "10.0.3",
|
||||
"pg": "8.23.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"server-only": "0.0.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"zod": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"dotenv": "17.4.2",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"jsdom": "30.0.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-tailwindcss": "0.8.1",
|
||||
"prisma": "7.10.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.13",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true,
|
||||
"esbuild@0.28.2": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sammelpruefung vor jedem Commit: Format, Lint, Typen, Tests, Shellskripte.
|
||||
* Bricht beim ersten Fehler ab und nennt den Schritt, der gefallen ist.
|
||||
*
|
||||
* Die Werkzeuge werden ueber ihren JavaScript-Einstiegspunkt gestartet, nicht
|
||||
* ueber npx. Das spart eine Shell und laeuft unter Windows wie unter Linux
|
||||
* gleich.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||
const istWindows = process.platform === 'win32'
|
||||
|
||||
/** Windows-Pfad in Schraegstrich-Schreibweise, wie Docker sie erwartet. */
|
||||
function mitSchraegstrich(pfad) {
|
||||
return pfad.split(sep).join('/')
|
||||
}
|
||||
|
||||
/** Ein mitgeliefertes Werkzeug im selben Node starten. */
|
||||
function werkzeug(paket, einstieg, argumente) {
|
||||
const ergebnis = spawnSync(
|
||||
process.execPath,
|
||||
[join(WURZEL, 'node_modules', paket, einstieg), ...argumente],
|
||||
{ cwd: WURZEL, stdio: 'inherit' },
|
||||
)
|
||||
return ergebnis.status ?? 1
|
||||
}
|
||||
|
||||
function programm(befehl, argumente, optionen = {}) {
|
||||
const ergebnis = spawnSync(befehl, argumente, {
|
||||
cwd: WURZEL,
|
||||
stdio: 'inherit',
|
||||
...optionen,
|
||||
})
|
||||
return ergebnis.status ?? 1
|
||||
}
|
||||
|
||||
function vorhanden(befehl) {
|
||||
const ergebnis = spawnSync(istWindows ? 'where' : 'which', [befehl], {
|
||||
cwd: WURZEL,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return ergebnis.status === 0
|
||||
}
|
||||
|
||||
const UEBERGEHEN = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'coverage',
|
||||
'logs',
|
||||
'sicherungen',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
])
|
||||
|
||||
/** Alle .sh-Dateien im Projekt, ohne node_modules und Konsorten. */
|
||||
function shellskripteFinden(verzeichnis = WURZEL, gefunden = []) {
|
||||
for (const eintrag of readdirSync(verzeichnis)) {
|
||||
if (UEBERGEHEN.has(eintrag)) continue
|
||||
const pfad = join(verzeichnis, eintrag)
|
||||
if (statSync(pfad).isDirectory()) {
|
||||
shellskripteFinden(pfad, gefunden)
|
||||
} else if (eintrag.endsWith('.sh')) {
|
||||
gefunden.push(mitSchraegstrich(relative(WURZEL, pfad)))
|
||||
}
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
function shellskriptePruefen() {
|
||||
const skripte = shellskripteFinden()
|
||||
if (skripte.length === 0) {
|
||||
console.log(' Keine Shellskripte gefunden.')
|
||||
return 0
|
||||
}
|
||||
console.log(` ${skripte.length} Skript(e): ${skripte.join(', ')}`)
|
||||
const argumente = ['--shell=bash', '--severity=style', ...skripte]
|
||||
|
||||
if (vorhanden('shellcheck')) {
|
||||
return programm('shellcheck', argumente)
|
||||
}
|
||||
|
||||
if (programm('docker', ['info'], { stdio: 'ignore' }) === 0) {
|
||||
console.log(' shellcheck ist nicht installiert, nehme das Docker-Abbild.')
|
||||
return programm('docker', [
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${mitSchraegstrich(WURZEL)}:/mnt`,
|
||||
'koalaman/shellcheck:stable',
|
||||
...argumente,
|
||||
])
|
||||
}
|
||||
|
||||
console.error(
|
||||
'Fehler: Weder shellcheck noch ein laufender Docker-Daemon gefunden. ' +
|
||||
'Entweder shellcheck installieren (https://www.shellcheck.net/) oder ' +
|
||||
'Docker starten. Die Shellskripte bleiben sonst ungeprueft.',
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
const schritte = [
|
||||
{
|
||||
name: 'Format (Prettier)',
|
||||
tun: () => werkzeug('prettier', 'bin/prettier.cjs', ['--check', '.']),
|
||||
},
|
||||
{
|
||||
name: 'Lint (ESLint)',
|
||||
tun: () => werkzeug('eslint', 'bin/eslint.js', ['.', '--max-warnings', '0']),
|
||||
},
|
||||
{
|
||||
name: 'Typen (TypeScript)',
|
||||
tun: () => werkzeug('typescript', 'bin/tsc', ['--noEmit']),
|
||||
},
|
||||
{ name: 'Tests (Vitest)', tun: () => werkzeug('vitest', 'vitest.mjs', ['run']) },
|
||||
{ name: 'Shellskripte (ShellCheck)', tun: shellskriptePruefen },
|
||||
]
|
||||
|
||||
// Ohne erzeugten Prisma-Client schlagen Typen und Tests fehl.
|
||||
if (!existsSync(join(WURZEL, 'src', 'generated', 'prisma', 'client.ts'))) {
|
||||
console.log('▸ Prisma-Client fehlt, erzeuge ihn')
|
||||
if (werkzeug('prisma', 'build/index.js', ['generate']) !== 0) process.exit(1)
|
||||
}
|
||||
|
||||
let gefallen = null
|
||||
for (const schritt of schritte) {
|
||||
console.log(`▸ ${schritt.name}`)
|
||||
if (schritt.tun() !== 0) {
|
||||
gefallen = schritt.name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (gefallen) {
|
||||
console.error(`✗ Gescheitert: ${gefallen}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('✓ Alle Pruefungen bestanden.')
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
# Zeiterfassung für den Planer.
|
||||
# Schreibt nach devlog/zeiten.csv: datum;start;ende;minuten;meilenstein;art;notiz
|
||||
set -euo pipefail
|
||||
|
||||
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CSV="$WURZEL/devlog/zeiten.csv"
|
||||
KOPF='datum;start;ende;minuten;meilenstein;art;notiz'
|
||||
OFFEN="$WURZEL/devlog/.zeit-offen"
|
||||
|
||||
hilfe() {
|
||||
cat <<'ENDE'
|
||||
Verwendung: zeit.sh <befehl> [argumente]
|
||||
|
||||
start <Meilenstein> [Notiz] Eintrag öffnen (Art: claude)
|
||||
stop [Notiz] Offenen Eintrag schließen
|
||||
nachtrag <Meilenstein> <Minuten> <Art> <Notiz>
|
||||
Eigene Zeit nachtragen
|
||||
(Art: review, test, einrichtung)
|
||||
summe [Meilenstein] Summen je Meilenstein und Art
|
||||
status Zeigt, ob ein Eintrag offen ist
|
||||
hilfe Diese Übersicht
|
||||
ENDE
|
||||
}
|
||||
|
||||
fehler() {
|
||||
printf 'Fehler: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Semikolons und Zeilenumbrüche würden die CSV zerlegen.
|
||||
saeubern() {
|
||||
printf '%s' "$1" | tr '\n\r;' ' ' | sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
|
||||
csv_anlegen() {
|
||||
mkdir -p "$(dirname "$CSV")"
|
||||
if [ ! -f "$CSV" ]; then
|
||||
printf '%s\n' "$KOPF" > "$CSV"
|
||||
fi
|
||||
}
|
||||
|
||||
# Minuten seit Mitternacht, damit auch ohne GNU date gerechnet werden kann.
|
||||
zu_minuten() {
|
||||
local uhr="$1"
|
||||
local h="${uhr%%:*}"
|
||||
local m="${uhr##*:}"
|
||||
printf '%s' "$((10#$h * 60 + 10#$m))"
|
||||
}
|
||||
|
||||
befehl_start() {
|
||||
local meilenstein="${1:-}"
|
||||
[ -n "$meilenstein" ] || fehler 'Meilenstein fehlt. Beispiel: zeit.sh start M1 "Gerüst"'
|
||||
shift
|
||||
local notiz
|
||||
notiz="$(saeubern "${*:-}")"
|
||||
|
||||
if [ -f "$OFFEN" ]; then
|
||||
local alt
|
||||
alt="$(cat "$OFFEN")"
|
||||
fehler "Es läuft bereits ein Eintrag ($alt). Erst 'zeit.sh stop'."
|
||||
fi
|
||||
|
||||
csv_anlegen
|
||||
mkdir -p "$(dirname "$OFFEN")"
|
||||
printf '%s;%s;%s;%s\n' "$(date '+%Y-%m-%d')" "$(date '+%H:%M')" \
|
||||
"$(saeubern "$meilenstein")" "$notiz" > "$OFFEN"
|
||||
printf 'Gestartet: %s um %s Uhr%s\n' "$meilenstein" "$(date '+%H:%M')" \
|
||||
"${notiz:+ – $notiz}"
|
||||
}
|
||||
|
||||
befehl_stop() {
|
||||
[ -f "$OFFEN" ] || fehler "Kein offener Eintrag. Erst 'zeit.sh start <Meilenstein>'."
|
||||
|
||||
local zeile datum start meilenstein notiz_start
|
||||
zeile="$(cat "$OFFEN")"
|
||||
IFS=';' read -r datum start meilenstein notiz_start <<< "$zeile"
|
||||
|
||||
local ende notiz
|
||||
ende="$(date '+%H:%M')"
|
||||
notiz="$(saeubern "${*:-}")"
|
||||
[ -n "$notiz" ] || notiz="$notiz_start"
|
||||
|
||||
local von bis minuten
|
||||
von="$(zu_minuten "$start")"
|
||||
bis="$(zu_minuten "$ende")"
|
||||
minuten=$((bis - von))
|
||||
# Über Mitternacht hinweg.
|
||||
[ "$minuten" -ge 0 ] || minuten=$((minuten + 1440))
|
||||
|
||||
csv_anlegen
|
||||
printf '%s;%s;%s;%s;%s;claude;%s\n' \
|
||||
"$datum" "$start" "$ende" "$minuten" "$meilenstein" "$notiz" >> "$CSV"
|
||||
rm -f "$OFFEN"
|
||||
printf 'Gestoppt: %s, %s Minuten (%s–%s)\n' "$meilenstein" "$minuten" "$start" "$ende"
|
||||
}
|
||||
|
||||
befehl_nachtrag() {
|
||||
local meilenstein="${1:-}" minuten="${2:-}" art="${3:-}"
|
||||
[ -n "$meilenstein" ] || fehler 'Meilenstein fehlt.'
|
||||
[ -n "$minuten" ] || fehler 'Minuten fehlen.'
|
||||
[ -n "$art" ] || fehler 'Art fehlt (review, test, einrichtung).'
|
||||
case "$minuten" in
|
||||
''|*[!0-9]*) fehler "Minuten müssen eine ganze Zahl sein, nicht '$minuten'." ;;
|
||||
esac
|
||||
shift 3
|
||||
local notiz
|
||||
notiz="$(saeubern "${*:-}")"
|
||||
[ -n "$notiz" ] || fehler 'Notiz fehlt.'
|
||||
|
||||
csv_anlegen
|
||||
printf '%s;;;%s;%s;%s;%s\n' \
|
||||
"$(date '+%Y-%m-%d')" "$minuten" "$(saeubern "$meilenstein")" \
|
||||
"$(saeubern "$art")" "$notiz" >> "$CSV"
|
||||
printf 'Nachgetragen: %s Minuten auf %s (%s)\n' "$minuten" "$meilenstein" "$art"
|
||||
}
|
||||
|
||||
befehl_summe() {
|
||||
local filter="${1:-}"
|
||||
[ -f "$CSV" ] || fehler "Noch keine Zeiten erfasst ($CSV fehlt)."
|
||||
|
||||
awk -F';' -v filter="$filter" '
|
||||
NR == 1 { next }
|
||||
$5 == "" { next }
|
||||
filter != "" && $5 != filter { next }
|
||||
{
|
||||
schluessel = $5 ";" $6
|
||||
summe[schluessel] += $4
|
||||
je_meilenstein[$5] += $4
|
||||
gesamt += $4
|
||||
if (!($5 in gesehen)) { gesehen[$5] = 1; reihenfolge[++anzahl] = $5 }
|
||||
}
|
||||
END {
|
||||
if (gesamt == 0) { print "Keine Einträge gefunden."; exit }
|
||||
printf "%-14s %-14s %10s %10s\n", "Meilenstein", "Art", "Minuten", "Stunden"
|
||||
printf "%s\n", "------------------------------------------------------"
|
||||
for (i = 1; i <= anzahl; i++) {
|
||||
m = reihenfolge[i]
|
||||
for (s in summe) {
|
||||
split(s, teil, ";")
|
||||
if (teil[1] == m) {
|
||||
printf "%-14s %-14s %10d %10.2f\n", m, teil[2], summe[s], summe[s] / 60
|
||||
}
|
||||
}
|
||||
printf "%-14s %-14s %10d %10.2f\n", m, "= Summe", je_meilenstein[m], je_meilenstein[m] / 60
|
||||
printf "%s\n", "------------------------------------------------------"
|
||||
}
|
||||
printf "%-29s %10d %10.2f\n", "Gesamt", gesamt, gesamt / 60
|
||||
}
|
||||
' "$CSV"
|
||||
}
|
||||
|
||||
befehl_status() {
|
||||
if [ -f "$OFFEN" ]; then
|
||||
local zeile datum start meilenstein notiz
|
||||
zeile="$(cat "$OFFEN")"
|
||||
IFS=';' read -r datum start meilenstein notiz <<< "$zeile"
|
||||
printf 'Offen seit %s %s Uhr: %s%s\n' "$datum" "$start" "$meilenstein" "${notiz:+ – $notiz}"
|
||||
else
|
||||
printf 'Kein offener Eintrag.\n'
|
||||
fi
|
||||
}
|
||||
|
||||
befehl="${1:-hilfe}"
|
||||
shift || true
|
||||
|
||||
case "$befehl" in
|
||||
start) befehl_start "$@" ;;
|
||||
stop) befehl_stop "$@" ;;
|
||||
nachtrag) befehl_nachtrag "$@" ;;
|
||||
summe) befehl_summe "$@" ;;
|
||||
status) befehl_status ;;
|
||||
hilfe|-h|--hilfe|--help) hilfe ;;
|
||||
*) fehler "Unbekannter Befehl '$befehl'. 'zeit.sh hilfe' zeigt die Übersicht." ;;
|
||||
esac
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
// Nach jedem Test das gerenderte DOM abraeumen, damit Tests sich nicht
|
||||
// gegenseitig sehen.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"exclude": ["node_modules", ".next", "out"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Loest den Alias @/ aus der tsconfig.json auf.
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
test: {
|
||||
// Die Zeitrechnung des Planers gilt in Europe/Berlin. Damit Tests nicht
|
||||
// von der Zeitzone des Rechners abhaengen, laufen sie in UTC – so faellt
|
||||
// sofort auf, wenn irgendwo die Ortszeit des Servers durchschlaegt.
|
||||
env: { TZ: 'UTC' },
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reportsDirectory: 'coverage',
|
||||
include: ['src/lib/**/*.ts'],
|
||||
exclude: ['**/*.test.ts'],
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'logik',
|
||||
environment: 'node',
|
||||
include: ['src/lib/**/*.test.ts', 'src/server/**/*.test.ts'],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'oberflaeche',
|
||||
environment: 'jsdom',
|
||||
include: ['src/components/**/*.test.tsx', 'src/app/**/*.test.tsx'],
|
||||
setupFiles: ['./test/aufbau-oberflaeche.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user