Initiale Arbeitszeiterfassung (Next.js 16 + SQLite)

Web-App zur Erfassung der Arbeitszeit, Layout/Architektur an Projekt logbuch
angelehnt, mit grüner Grundfarbe und SQLite3 statt MySQL.

- Eingabemaske: Datum (mit Wochentag), Ort (Kunde FFM/Homeoffice/Andrena),
  Beginn/Ende, Reisezeit, Vertriebsunterstützung (default 2h), Kommentar (max 500)
- Berechnung: Arbeitszeit = Ende-Beginn; Mehrarbeit = Arbeitszeit - 8h - Pause
- Pause als globale Einstellung (Einstellungen-Tab)
- Tabs Eingabe/Liste/Einstellungen, letzte 10 Einträge unter der Maske
- Single-User-Login (Benutzer Birgit + Passwort fest in .env), JWT-Cookie via jose
- better-sqlite3, Docker-Setup für docker.citysensor.de

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:53:44 +02:00
commit 01a7ac8e0b
38 changed files with 9062 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
// Single-User-Authentifizierung: Benutzername und Passwort fest in .env hinterlegt.
const DEFAULT_USERNAME = 'Birgit';
export function getConfiguredUsername(): string {
return process.env.APP_USERNAME || DEFAULT_USERNAME;
}
export function verifyCredentials(username: string, password: string): boolean {
const expectedUser = getConfiguredUsername();
const expectedPw = process.env.APP_PASSWORD;
if (!expectedPw) throw new Error('APP_PASSWORD Umgebungsvariable ist nicht gesetzt!');
// Benutzername case-insensitiv, Passwort exakt.
return username.trim().toLowerCase() === expectedUser.toLowerCase() && password === expectedPw;
}
+56
View File
@@ -0,0 +1,56 @@
// Reine Hilfsfunktionen für Datum-/Zeit-Berechnungen (ohne DB-Zugriff).
const WOCHENTAGE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
];
/** "HH:MM" → Minuten seit Mitternacht, oder null bei ungültiger Eingabe. */
export function parseHM(hm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hm ?? '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return h * 60 + min;
}
/**
* Differenz zwischen Beginn und Ende in dezimalen Stunden.
* Gibt 0 zurück, wenn eine Zeit ungültig ist. Ende vor Beginn ⇒ 0.
*/
export function toDecimalHours(beginn: string, ende: string): number {
const b = parseHM(beginn);
const e = parseHM(ende);
if (b === null || e === null) return 0;
const diff = e - b;
if (diff <= 0) return 0;
return diff / 60;
}
/** Mehrarbeit = Arbeitszeit 8 Stunden Pause (kann negativ sein). */
export function mehrarbeit(arbeitszeitStd: number, pauseStd: number): number {
return arbeitszeitStd - 8 - pauseStd;
}
/** Deutscher Wochentagsname für ein "YYYY-MM-DD"-Datum, oder "" bei ungültig. */
export function weekdayName(datum: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datum ?? '');
if (!m) return '';
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (isNaN(d.getTime())) return '';
return WOCHENTAGE[d.getDay()];
}
/** Dezimalstunden deutsch formatieren, z.B. 0.5 → "0,5". */
export function formatHours(value: number): string {
return value.toLocaleString('de-DE', {
minimumFractionDigits: 1,
maximumFractionDigits: 2,
});
}
+49
View File
@@ -0,0 +1,49 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
let db: Database.Database | null = null;
function dbPath(): string {
return process.env.DB_PATH || './data/arbeitszeit.db';
}
/** Singleton-Zugriff auf die SQLite-Datenbank. Legt Datei + Schema beim ersten Aufruf an. */
export function getDb(): Database.Database {
if (db) return db;
const path = dbPath();
mkdirSync(dirname(path), { recursive: true });
db = new Database(path);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS arbeitszeit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum TEXT NOT NULL,
ort TEXT NOT NULL,
beginn TEXT NOT NULL,
ende TEXT NOT NULL,
reisezeit REAL NOT NULL DEFAULT 0,
vertriebsunterstuetzung REAL NOT NULL DEFAULT 2,
kommentar TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_arbeitszeit_datum ON arbeitszeit (datum);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Standard-Pause beim ersten Start setzen (0,5 h)
db.prepare(
`INSERT INTO settings (key, value) VALUES ('pause', '0.5')
ON CONFLICT(key) DO NOTHING`
).run();
return db;
}
+38
View File
@@ -0,0 +1,38 @@
// In-memory rate limiter funktioniert pro Prozess (single Docker container).
// Erlaubt MAX_ATTEMPTS Versuche pro IP innerhalb WINDOW_MS Millisekunden.
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000; // 15 Minuten
interface Entry {
count: number;
resetAt: number;
}
const store = new Map<string, Entry>();
// Aufräumen abgelaufener Einträge alle 5 Minuten
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt < now) store.delete(key);
}
}, 5 * 60 * 1000);
export function checkRateLimit(ip: string): { allowed: boolean; remainingMs: number } {
const now = Date.now();
const entry = store.get(ip);
if (!entry || entry.resetAt < now) {
store.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remainingMs: 0 };
}
entry.count += 1;
if (entry.count > MAX_ATTEMPTS) {
return { allowed: false, remainingMs: entry.resetAt - now };
}
return { allowed: true, remainingMs: 0 };
}
+89
View File
@@ -0,0 +1,89 @@
import { getDb } from './db';
import type { ArbeitszeitEintrag, ArbeitszeitInput } from '@/types/arbeitszeit';
interface ListParams {
limit: number;
offset: number;
month?: string; // "YYYY-MM"
}
interface ListResult {
entries: ArbeitszeitEintrag[];
total: number;
}
/** Einträge absteigend nach Datum (neueste zuerst), optional nach Monat gefiltert. */
export function listArbeitszeit({ limit, offset, month }: ListParams): ListResult {
const db = getDb();
const where = month ? `WHERE substr(datum, 1, 7) = ?` : '';
const args = month ? [month] : [];
const total = (
db.prepare(`SELECT COUNT(*) AS c FROM arbeitszeit ${where}`).get(...args) as { c: number }
).c;
const entries = db
.prepare(
`SELECT * FROM arbeitszeit ${where}
ORDER BY datum DESC, beginn DESC, id DESC
LIMIT ? OFFSET ?`
)
.all(...args, limit, offset) as ArbeitszeitEintrag[];
return { entries, total };
}
export function getArbeitszeit(id: number): ArbeitszeitEintrag | undefined {
return getDb().prepare('SELECT * FROM arbeitszeit WHERE id = ?').get(id) as
| ArbeitszeitEintrag
| undefined;
}
export function createArbeitszeit(input: ArbeitszeitInput): ArbeitszeitEintrag {
const db = getDb();
const info = db
.prepare(
`INSERT INTO arbeitszeit (datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar)
VALUES (@datum, @ort, @beginn, @ende, @reisezeit, @vertriebsunterstuetzung, @kommentar)`
)
.run(input);
return getArbeitszeit(Number(info.lastInsertRowid))!;
}
export function updateArbeitszeit(
id: number,
input: ArbeitszeitInput
): ArbeitszeitEintrag | undefined {
const db = getDb();
db.prepare(
`UPDATE arbeitszeit SET
datum = @datum, ort = @ort, beginn = @beginn, ende = @ende,
reisezeit = @reisezeit, vertriebsunterstuetzung = @vertriebsunterstuetzung,
kommentar = @kommentar
WHERE id = @id`
).run({ ...input, id });
return getArbeitszeit(id);
}
export function deleteArbeitszeit(id: number): boolean {
return getDb().prepare('DELETE FROM arbeitszeit WHERE id = ?').run(id).changes > 0;
}
// --- Einstellungen -------------------------------------------------------
export function getPause(): number {
const row = getDb().prepare(`SELECT value FROM settings WHERE key = 'pause'`).get() as
| { value: string }
| undefined;
const v = row ? parseFloat(row.value) : 0.5;
return isNaN(v) ? 0.5 : v;
}
export function setPause(value: number): void {
getDb()
.prepare(
`INSERT INTO settings (key, value) VALUES ('pause', @value)
ON CONFLICT(key) DO UPDATE SET value = @value`
)
.run({ value: String(value) });
}
+62
View File
@@ -0,0 +1,62 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
const SESSION_DURATION = 8 * 60 * 60 * 1000; // 8 Stunden
function getKey(): Uint8Array {
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
return new TextEncoder().encode(secretKey);
}
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
async function encrypt(payload: SessionData): Promise<string> {
return new SignJWT(payload as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(getKey());
}
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData;
} catch {
return null;
}
}
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = { ...data, expiresAt };
const encrypted = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) return null;
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) return null;
return session;
}
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
+39
View File
@@ -0,0 +1,39 @@
import { ORTE, type ArbeitszeitInput, type Ort } from '@/types/arbeitszeit';
import { parseHM } from './calc';
/**
* Validiert und normalisiert einen JSON-Body zu einem ArbeitszeitInput.
* Wirft Error mit deutscher Meldung bei ungültigen Daten.
*/
export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
const b = (body ?? {}) as Record<string, unknown>;
const datum = String(b.datum ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(datum)) throw new Error('Ungültiges Datum.');
const ort = String(b.ort ?? '') as Ort;
if (!ORTE.includes(ort)) throw new Error('Ungültiger Ort.');
const beginn = String(b.beginn ?? '');
const ende = String(b.ende ?? '');
if (parseHM(beginn) === null) throw new Error('Ungültige Startzeit.');
if (parseHM(ende) === null) throw new Error('Ungültige Endzeit.');
const reisezeit = toNumber(b.reisezeit, 0);
const vertriebsunterstuetzung = toNumber(b.vertriebsunterstuetzung, 2);
if (reisezeit < 0 || vertriebsunterstuetzung < 0) {
throw new Error('Zeitwerte dürfen nicht negativ sein.');
}
const kommentar = String(b.kommentar ?? '').slice(0, 500);
return { datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar };
}
function toNumber(value: unknown, fallback: number): number {
if (value === '' || value === null || value === undefined) return fallback;
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.'));
return isNaN(n) ? fallback : n;
}
export { toNumber };