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:
Florian Wilde
2026-09-10 12:41:28 +02:00
co-authored by Claude Opus 5
parent 8220adc449
commit de1f923767
18 changed files with 13414 additions and 0 deletions
+145
View File
@@ -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.')
+175
View File
@@ -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