diff --git a/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql b/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql new file mode 100644 index 0000000..6a56ffc --- /dev/null +++ b/prisma/migrations/20260910135802_aufgaben_und_projekte/migration.sql @@ -0,0 +1,80 @@ +-- CreateEnum +CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL'); + +-- CreateEnum +CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'); + +-- CreateEnum +CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP'); + +-- CreateEnum +CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'); + +-- CreateTable +CREATE TABLE "projekt" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "beschreibung" TEXT, + "status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV', + "bereichId" TEXT, + "zielAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "projekt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "aufgabe" ( + "id" TEXT NOT NULL, + "titel" TEXT NOT NULL, + "notiz" TEXT, + "typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE', + "status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN', + "prioritaet" INTEGER NOT NULL DEFAULT 3, + "aufwandMinuten" INTEGER, + "faelligAm" TIMESTAMP(3), + "erledigtAm" TIMESTAMP(3), + "bereichId" TEXT, + "projektId" TEXT, + "quelle" "Quelle" NOT NULL DEFAULT 'MANUELL', + "quelleUrl" TEXT, + "quelleRef" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "projekt_status_idx" ON "projekt"("status"); + +-- CreateIndex +CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId"); + +-- CreateIndex +CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name"); + +-- CreateIndex +CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm"); + +-- CreateIndex +CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status"); + +-- CreateIndex +CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId"); + +-- CreateIndex +CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef"); + +-- AddForeignKey +ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da39cac..38e858b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,8 +2,8 @@ // Fachbegriffe auf Deutsch, technische Felder auf Englisch. generator client { - provider = "prisma-client" - output = "../src/generated/prisma" + provider = "prisma-client" + output = "../src/generated/prisma" moduleFormat = "esm" } @@ -18,18 +18,18 @@ enum Rolle { /// Ein Mensch, der sich anmelden darf. model Benutzer { - id String @id @default(cuid()) - email String @unique - name String? + id String @id @default(cuid()) + email String @unique + name String? /// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist. - passwortHash String? - rolle Rolle @default(BENUTZER) - aktiv Boolean @default(true) - bildUrl String? - emailGeprueft DateTime? - zuletztAktiv DateTime? - erstelltAm DateTime @default(now()) - aktualisiertAm DateTime @updatedAt + passwortHash String? + rolle Rolle @default(BENUTZER) + aktiv Boolean @default(true) + bildUrl String? + emailGeprueft DateTime? + zuletztAktiv DateTime? + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt konten Konto[] passkeys Passkey[] @@ -63,19 +63,19 @@ model Konto { /// Ein registrierter Passkey (WebAuthn) eines Benutzers. model Passkey { - id String @id @default(cuid()) - benutzerId String + id String @id @default(cuid()) + benutzerId String /// Base64URL der Credential-ID. - credentialId String @unique - publicKey Bytes - counter BigInt @default(0) + credentialId String @unique + publicKey Bytes + counter BigInt @default(0) /// Kommagetrennt, z. B. "internal,hybrid". - transports String? - geraetetyp String? - gesichert Boolean @default(false) - bezeichnung String - erstelltAm DateTime @default(now()) - zuletztBenutzt DateTime? + transports String? + geraetetyp String? + gesichert Boolean @default(false) + bezeichnung String + erstelltAm DateTime @default(now()) + zuletztBenutzt DateTime? benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) @@ -98,12 +98,12 @@ model WebauthnChallenge { /// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster. model Bereich { - id String @id @default(cuid()) - name String @unique + id String @id @default(cuid()) + name String @unique /// Hex-Farbe inklusive Raute, z. B. "#2563eb". - farbe String - reihenfolge Int @default(0) - aktiv Boolean @default(true) + farbe String + reihenfolge Int @default(0) + aktiv Boolean @default(true) /// Tageskapazitaet in Minuten, Montag bis Sonntag. kapazitaetMo Int @default(0) @@ -121,23 +121,129 @@ model Bereich { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt + aufgaben Aufgabe[] + projekte Projekt[] + @@index([aktiv, reihenfolge]) @@map("bereich") } +enum AufgabenTyp { + AUFGABE + IDEE + ARBEITSPAKET + MEILENSTEIN + SPIEGEL +} + +enum AufgabenStatus { + OFFEN + GEPLANT + WARTET + ERLEDIGT + VERWORFEN +} + +/// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef]. +enum Quelle { + MANUELL + ZAMMAD + WARTUNGSPLANER + TELEGRAM + SPIEGEL + MAIL + API + MCP +} + +enum ProjektStatus { + AKTIV + PAUSIERT + ABGESCHLOSSEN + VERWORFEN +} + +/// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine +/// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben. +model Projekt { + id String @id @default(cuid()) + name String + beschreibung String? + status ProjektStatus @default(AKTIV) + bereichId String? + /// Wunschtermin fuer den Abschluss, rein informativ. + zielAm DateTime? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) + aufgaben Aufgabe[] + + @@unique([name]) + @@index([status]) + @@index([bereichId]) + @@map("projekt") +} + +/// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein. +/// +/// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied, +/// "einsortieren" heisst schlicht: einen Bereich setzen. +model Aufgabe { + id String @id @default(cuid()) + titel String + notiz String? + typ AufgabenTyp @default(AUFGABE) + status AufgabenStatus @default(OFFEN) + + /// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall. + prioritaet Int @default(3) + /// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt". + aufwandMinuten Int? + + faelligAm DateTime? + erledigtAm DateTime? + + bereichId String? + projektId String? + + /// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis. + quelle Quelle @default(MANUELL) + quelleUrl String? + /// Kennung beim Ursprungssystem, z. B. die Ticketnummer. + quelleRef String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) + projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) + + /// Integrationen upserten immer ueber diese beiden Felder. Von Hand + /// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte + /// als verschieden an, sie behindern sich also nicht. + @@unique([quelle, quelleRef]) + @@index([status, faelligAm]) + @@index([bereichId, status]) + @@index([projektId]) + @@index([typ, status]) + @@map("aufgabe") +} + /// Unveraenderlicher Nachweis jeder schreibenden Aktion. model AuditEintrag { - id String @id @default(cuid()) - zeitpunkt DateTime @default(now()) - benutzerId String? + id String @id @default(cuid()) + zeitpunkt DateTime @default(now()) + benutzerId String? /// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird. benutzerEmail String - aktion String - objektTyp String - objektId String? + aktion String + objektTyp String + objektId String? /// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs. - details Json? - instanz String + details Json? + instanz String benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) diff --git a/src/lib/aufgabe-anzeige.test.ts b/src/lib/aufgabe-anzeige.test.ts new file mode 100644 index 0000000..2d78313 --- /dev/null +++ b/src/lib/aufgabe-anzeige.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { faelligkeitFarbe, fuerAnzeige, type AufgabeRoh } from './aufgabe-anzeige' + +// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit. +const jetzt = new Date('2026-03-17T09:00:00Z') + +function roh(teil: Partial = {}): AufgabeRoh { + return { + id: 'a1', + titel: 'Angebot schreiben', + notiz: null, + typ: 'AUFGABE', + status: 'OFFEN', + prioritaet: 3, + aufwandMinuten: 60, + faelligAm: null, + quelle: 'MANUELL', + quelleUrl: null, + bereich: null, + projekt: null, + ...teil, + } +} + +describe('fuerAnzeige', () => { + it('lässt den Datumswert leer, wenn kein Termin gesetzt ist', () => { + const anzeige = fuerAnzeige(roh(), jetzt) + expect(anzeige.faelligWert).toBe('') + expect(anzeige.faelligDatum).toBeNull() + expect(anzeige.faelligText).toBe('ohne Termin') + expect(anzeige.ueberfaellig).toBe(false) + }) + + it('gibt den Datumswert im Format des Eingabefelds zurück', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }), + jetzt, + ) + expect(anzeige.faelligWert).toBe('2026-03-20') + }) + + it('nimmt den Berliner Kalendertag, nicht den UTC-Tag', () => { + // 23:30 UTC am 19. ist in Berlin bereits der 20. um 00:30. + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-19T23:30:00Z') }), + jetzt, + ) + expect(anzeige.faelligWert).toBe('2026-03-20') + }) + + it('markiert eine offene Aufgabe von gestern als überfällig', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-16T09:00:00Z') }), + jetzt, + ) + expect(anzeige.ueberfaellig).toBe(true) + expect(anzeige.faelligText).toBe('seit gestern überfällig') + }) + + it('markiert eine erledigte Aufgabe von gestern nicht als überfällig', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-16T09:00:00Z'), status: 'ERLEDIGT' }), + jetzt, + ) + expect(anzeige.ueberfaellig).toBe(false) + }) + + it('schreibt das Datum ausgeschrieben für den Hinweis', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }), + jetzt, + ) + expect(anzeige.faelligDatum).toBe('Fr., 20.03.2026') + }) + + it('reicht Bereich und Projekt unverändert durch', () => { + const anzeige = fuerAnzeige( + roh({ + bereich: { id: 'b1', name: 'Gewerbe', farbe: '#7c3aed' }, + projekt: { id: 'p1', name: 'Website neu' }, + }), + jetzt, + ) + expect(anzeige.bereich?.name).toBe('Gewerbe') + expect(anzeige.projekt?.name).toBe('Website neu') + }) +}) + +describe('faelligkeitFarbe', () => { + it('warnt rot bei Überfälligkeit', () => { + const anzeige = fuerAnzeige( + roh({ faelligAm: new Date('2026-03-15T09:00:00Z') }), + jetzt, + ) + expect(faelligkeitFarbe(anzeige)).toBe('gefahr') + }) + + it('warnt gelb für heute und morgen', () => { + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-03-17T15:00:00Z') }), jetzt), + ), + ).toBe('warnung') + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-03-18T15:00:00Z') }), jetzt), + ), + ).toBe('warnung') + }) + + it('bleibt bei allem Weiteren unauffällig', () => { + expect( + faelligkeitFarbe( + fuerAnzeige(roh({ faelligAm: new Date('2026-04-01T09:00:00Z') }), jetzt), + ), + ).toBe('neutral') + expect(faelligkeitFarbe(fuerAnzeige(roh(), jetzt))).toBe('neutral') + }) +}) diff --git a/src/lib/aufgabe-anzeige.ts b/src/lib/aufgabe-anzeige.ts new file mode 100644 index 0000000..2403aa7 --- /dev/null +++ b/src/lib/aufgabe-anzeige.ts @@ -0,0 +1,96 @@ +import type { AufgabenStatus, AufgabenTyp, Quelle } from '@/generated/prisma/enums' +import { + faelligkeitEinordnen, + faelligkeitText, + istUeberfaellig, + type Faelligkeit, +} from '@/lib/aufgabe' +import { alsDatum, alsTagesschluessel } from '@/lib/zeit' + +/** + * Bereitet eine Aufgabe für die Anzeige auf. + * + * Reine Funktion mit dem Zeitpunkt als Parameter: Formatierung und + * Fälligkeitsrechnung passieren serverseitig in Europe/Berlin, damit im + * Browser nichts nachgerechnet wird und alle Listen gleich aussehen. + */ + +export type AufgabeRoh = { + id: string + titel: string + notiz: string | null + typ: AufgabenTyp + status: AufgabenStatus + prioritaet: number + aufwandMinuten: number | null + faelligAm: Date | null + quelle: Quelle + quelleUrl: string | null + bereich: { id: string; name: string; farbe: string } | null + projekt: { id: string; name: string } | null +} + +export type AufgabeAnzeige = { + id: string + titel: string + notiz: string | null + typ: AufgabenTyp + status: AufgabenStatus + prioritaet: number + aufwandMinuten: number | null + /** ISO-Datum für das Formularfeld, sonst leer. */ + faelligWert: string + /** "heute fällig", "seit 3 Tagen überfällig", "ohne Termin". */ + faelligText: string + /** Ausgeschriebenes Datum für den Titelhinweis, sonst null. */ + faelligDatum: string | null + faelligkeit: Faelligkeit['art'] + ueberfaellig: boolean + quelle: Quelle + quelleUrl: string | null + bereich: { id: string; name: string; farbe: string } | null + projekt: { id: string; name: string } | null +} + +/** + * ISO-Datum in Europe/Berlin, passend für ein . + * Der Wert des Feldes ist ein reines Datum ohne Zeitzone, also der Berliner + * Kalendertag — dieselbe Rechnung wie überall sonst im Planer. + */ +function alsDatumswert(zeitpunkt: Date | null): string { + return zeitpunkt ? alsTagesschluessel(zeitpunkt) : '' +} + +export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige { + const faelligkeit = faelligkeitEinordnen(aufgabe.faelligAm, jetzt) + + return { + id: aufgabe.id, + titel: aufgabe.titel, + notiz: aufgabe.notiz, + typ: aufgabe.typ, + status: aufgabe.status, + prioritaet: aufgabe.prioritaet, + aufwandMinuten: aufgabe.aufwandMinuten, + faelligWert: alsDatumswert(aufgabe.faelligAm), + faelligText: faelligkeitText(faelligkeit), + faelligDatum: aufgabe.faelligAm ? alsDatum(aufgabe.faelligAm) : null, + faelligkeit: faelligkeit.art, + ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt), + quelle: aufgabe.quelle, + quelleUrl: aufgabe.quelleUrl, + bereich: aufgabe.bereich, + projekt: aufgabe.projekt, + } +} + +/** Wie dringend die Fälligkeit aussieht — steuert die Farbe des Kennzeichens. */ +export function faelligkeitFarbe( + anzeige: AufgabeAnzeige, +): 'neutral' | 'warnung' | 'gefahr' { + if (anzeige.ueberfaellig) return 'gefahr' + if (anzeige.faelligkeit === 'heute' || anzeige.faelligkeit === 'morgen') { + return 'warnung' + } + return 'neutral' +} diff --git a/src/lib/aufgabe.test.ts b/src/lib/aufgabe.test.ts new file mode 100644 index 0000000..130d79d --- /dev/null +++ b/src/lib/aufgabe.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest' +import { + aufgabeSchema, + aufwandSumme, + faelligkeitEinordnen, + faelligkeitText, + fortschrittBerechnen, + istAbgeschlossen, + istOffen, + istUeberfaellig, + moeglicheUebergaenge, + nachDringlichkeit, + projektSchema, + schnellerfassungSchema, + uebergangErlaubt, + type SortierbareAufgabe, +} from './aufgabe' + +// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit. +const jetzt = new Date('2026-03-17T09:00:00Z') + +describe('istOffen und istAbgeschlossen', () => { + it('trennt die beiden Gruppen vollständig', () => { + for (const status of ['OFFEN', 'GEPLANT', 'WARTET'] as const) { + expect(istOffen(status)).toBe(true) + expect(istAbgeschlossen(status)).toBe(false) + } + for (const status of ['ERLEDIGT', 'VERWORFEN'] as const) { + expect(istOffen(status)).toBe(false) + expect(istAbgeschlossen(status)).toBe(true) + } + }) +}) + +describe('uebergangErlaubt', () => { + it('lässt den Wechsel auf sich selbst zu', () => { + expect(uebergangErlaubt('OFFEN', 'OFFEN')).toBe(true) + }) + + it('lässt aus offenen Zuständen alles zu', () => { + expect(uebergangErlaubt('OFFEN', 'ERLEDIGT')).toBe(true) + expect(uebergangErlaubt('GEPLANT', 'WARTET')).toBe(true) + expect(uebergangErlaubt('WARTET', 'VERWORFEN')).toBe(true) + }) + + it('führt aus abgeschlossenen Zuständen nur zurück nach OFFEN', () => { + expect(uebergangErlaubt('ERLEDIGT', 'OFFEN')).toBe(true) + expect(uebergangErlaubt('ERLEDIGT', 'GEPLANT')).toBe(false) + expect(uebergangErlaubt('VERWORFEN', 'OFFEN')).toBe(true) + expect(uebergangErlaubt('VERWORFEN', 'ERLEDIGT')).toBe(false) + }) + + it('nennt für einen abgeschlossenen Zustand genau einen Ausweg', () => { + expect(moeglicheUebergaenge('ERLEDIGT')).toEqual(['OFFEN']) + }) +}) + +describe('faelligkeitEinordnen', () => { + it('erkennt fehlenden Termin', () => { + expect(faelligkeitEinordnen(null, jetzt)).toEqual({ art: 'ohne' }) + expect(faelligkeitEinordnen(undefined, jetzt)).toEqual({ art: 'ohne' }) + }) + + it('erkennt heute, auch spät am Abend', () => { + expect(faelligkeitEinordnen(new Date('2026-03-17T22:00:00Z'), jetzt)).toEqual({ + art: 'heute', + }) + }) + + it('rechnet in Kalendertagen, nicht in 24-Stunden-Schritten', () => { + // 23:30 UTC am 17. ist in Berlin bereits der 18. um 00:30. + expect(faelligkeitEinordnen(new Date('2026-03-17T23:30:00Z'), jetzt)).toEqual({ + art: 'morgen', + }) + }) + + it('erkennt morgen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-18T09:00:00Z'), jetzt)).toEqual({ + art: 'morgen', + }) + }) + + it('fasst die nächste Woche zusammen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-22T09:00:00Z'), jetzt)).toEqual({ + art: 'diese-woche', + tage: 5, + }) + expect(faelligkeitEinordnen(new Date('2026-03-24T09:00:00Z'), jetzt)).toEqual({ + art: 'diese-woche', + tage: 7, + }) + }) + + it('schiebt alles darüber nach später', () => { + expect(faelligkeitEinordnen(new Date('2026-03-25T09:00:00Z'), jetzt)).toEqual({ + art: 'spaeter', + tage: 8, + }) + }) + + it('zählt Überfälligkeit in Tagen', () => { + expect(faelligkeitEinordnen(new Date('2026-03-16T09:00:00Z'), jetzt)).toEqual({ + art: 'ueberfaellig', + tage: 1, + }) + expect(faelligkeitEinordnen(new Date('2026-03-10T09:00:00Z'), jetzt)).toEqual({ + art: 'ueberfaellig', + tage: 7, + }) + }) +}) + +describe('faelligkeitText', () => { + it('schreibt jeden Fall auf Deutsch', () => { + expect(faelligkeitText({ art: 'ohne' })).toBe('ohne Termin') + expect(faelligkeitText({ art: 'heute' })).toBe('heute fällig') + expect(faelligkeitText({ art: 'morgen' })).toBe('morgen fällig') + expect(faelligkeitText({ art: 'diese-woche', tage: 3 })).toBe('in 3 Tagen fällig') + expect(faelligkeitText({ art: 'spaeter', tage: 20 })).toBe('in 20 Tagen fällig') + }) + + it('sagt bei einem Tag Rückstand "seit gestern"', () => { + expect(faelligkeitText({ art: 'ueberfaellig', tage: 1 })).toBe( + 'seit gestern überfällig', + ) + expect(faelligkeitText({ art: 'ueberfaellig', tage: 4 })).toBe( + 'seit 4 Tagen überfällig', + ) + }) +}) + +describe('istUeberfaellig', () => { + it('erkennt eine offene Aufgabe von gestern', () => { + expect(istUeberfaellig(new Date('2026-03-16T09:00:00Z'), 'OFFEN', jetzt)).toBe(true) + }) + + it('zählt den heutigen Tag nicht als überfällig', () => { + expect(istUeberfaellig(new Date('2026-03-17T06:00:00Z'), 'OFFEN', jetzt)).toBe(false) + }) + + it('lässt Erledigtes und Verworfenes in Ruhe', () => { + const gestern = new Date('2026-03-16T09:00:00Z') + expect(istUeberfaellig(gestern, 'ERLEDIGT', jetzt)).toBe(false) + expect(istUeberfaellig(gestern, 'VERWORFEN', jetzt)).toBe(false) + }) + + it('ist ohne Termin nie überfällig', () => { + expect(istUeberfaellig(null, 'OFFEN', jetzt)).toBe(false) + }) +}) + +describe('nachDringlichkeit', () => { + function auf(teil: Partial): SortierbareAufgabe { + return { + status: 'OFFEN', + prioritaet: 3, + faelligAm: null, + titel: 'Titel', + ...teil, + } + } + + it('stellt Offenes vor Abgeschlossenes', () => { + expect( + nachDringlichkeit(auf({ status: 'ERLEDIGT' }), auf({ status: 'OFFEN' })), + ).toBeGreaterThan(0) + }) + + it('stellt den früheren Termin nach vorn', () => { + const frueh = auf({ faelligAm: new Date('2026-03-18T00:00:00Z') }) + const spaet = auf({ faelligAm: new Date('2026-03-20T00:00:00Z') }) + expect(nachDringlichkeit(frueh, spaet)).toBeLessThan(0) + }) + + it('stellt Terminiertes vor Unterminiertes', () => { + const mitTermin = auf({ faelligAm: new Date('2026-04-01T00:00:00Z') }) + const ohneTermin = auf({ faelligAm: null }) + expect(nachDringlichkeit(mitTermin, ohneTermin)).toBeLessThan(0) + }) + + it('entscheidet bei gleichem Termin über die Priorität', () => { + const termin = new Date('2026-03-18T00:00:00Z') + expect( + nachDringlichkeit( + auf({ faelligAm: termin, prioritaet: 1 }), + auf({ faelligAm: termin, prioritaet: 4 }), + ), + ).toBeLessThan(0) + }) + + it('sortiert zuletzt nach dem Titel, mit deutscher Sortierung', () => { + expect( + nachDringlichkeit(auf({ titel: 'Äpfel' }), auf({ titel: 'Banane' })), + ).toBeLessThan(0) + }) + + it('ergibt eine stabile Gesamtreihenfolge', () => { + const liste: SortierbareAufgabe[] = [ + auf({ titel: 'Erledigt', status: 'ERLEDIGT' }), + auf({ titel: 'Ohne Termin' }), + auf({ titel: 'Übermorgen', faelligAm: new Date('2026-03-19T00:00:00Z') }), + auf({ titel: 'Morgen', faelligAm: new Date('2026-03-18T00:00:00Z') }), + ] + expect([...liste].sort(nachDringlichkeit).map((a) => a.titel)).toEqual([ + 'Morgen', + 'Übermorgen', + 'Ohne Termin', + 'Erledigt', + ]) + }) +}) + +describe('fortschrittBerechnen', () => { + it('ist bei keiner Aufgabe null Prozent', () => { + expect(fortschrittBerechnen([])).toEqual({ + gesamt: 0, + erledigt: 0, + offen: 0, + prozent: 0, + }) + }) + + it('rechnet Erledigtes gegen die Gesamtzahl', () => { + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'ERLEDIGT' }, + { status: 'OFFEN' }, + { status: 'GEPLANT' }, + ]), + ).toEqual({ gesamt: 4, erledigt: 2, offen: 2, prozent: 50 }) + }) + + it('lässt Verworfenes ganz aus der Rechnung', () => { + // Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde. + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'VERWORFEN' }, + { status: 'OFFEN' }, + ]), + ).toEqual({ gesamt: 2, erledigt: 1, offen: 1, prozent: 50 }) + }) + + it('rundet auf ganze Prozent', () => { + expect( + fortschrittBerechnen([ + { status: 'ERLEDIGT' }, + { status: 'OFFEN' }, + { status: 'OFFEN' }, + ]).prozent, + ).toBe(33) + }) +}) + +describe('aufwandSumme', () => { + it('summiert und zählt Ungeschätztes als null', () => { + expect( + aufwandSumme([ + { aufwandMinuten: 30 }, + { aufwandMinuten: null }, + { aufwandMinuten: 45 }, + ]), + ).toBe(75) + }) + + it('ist bei leerer Liste null', () => { + expect(aufwandSumme([])).toBe(0) + }) +}) + +describe('aufgabeSchema', () => { + const gueltig = { + titel: 'Angebot schreiben', + notiz: '', + typ: 'AUFGABE', + status: 'OFFEN', + prioritaet: '2', + aufwandMinuten: '90', + faelligAm: '2026-03-20', + bereichId: '', + projektId: '', + } + + it('nimmt ein vollständiges Formular an', () => { + const ergebnis = aufgabeSchema.parse(gueltig) + expect(ergebnis.titel).toBe('Angebot schreiben') + expect(ergebnis.prioritaet).toBe(2) + expect(ergebnis.aufwandMinuten).toBe(90) + expect(ergebnis.faelligAm).toBeInstanceOf(Date) + }) + + it('macht aus leeren Feldern null statt leerer Zeichenketten', () => { + const ergebnis = aufgabeSchema.parse(gueltig) + expect(ergebnis.notiz).toBeNull() + expect(ergebnis.bereichId).toBeNull() + expect(ergebnis.projektId).toBeNull() + }) + + it('lässt Aufwand und Termin weg, wenn nichts angegeben ist', () => { + const ergebnis = aufgabeSchema.parse({ + ...gueltig, + aufwandMinuten: '', + faelligAm: '', + }) + expect(ergebnis.aufwandMinuten).toBeNull() + expect(ergebnis.faelligAm).toBeNull() + }) + + it('weist einen zu kurzen Titel ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, titel: 'A' }).success).toBe(false) + }) + + it('hält die Priorität zwischen 1 und 5', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '0' }).success).toBe(false) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '6' }).success).toBe(false) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '1' }).success).toBe(true) + expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '5' }).success).toBe(true) + }) + + it('weist einen Aufwand über acht Stunden ab', () => { + // Genau acht Stunden sind noch erlaubt, eine Minute mehr nicht. + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '480' }).success).toBe( + true, + ) + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '481' }).success).toBe( + false, + ) + }) + + it('weist einen Aufwand von null oder weniger ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '0' }).success).toBe( + false, + ) + }) + + it('weist ein unsinniges Datum ab', () => { + expect(aufgabeSchema.safeParse({ ...gueltig, faelligAm: 'übermorgen' }).success).toBe( + false, + ) + }) +}) + +describe('schnellerfassungSchema', () => { + it('kommt mit einem Titel aus', () => { + expect(schnellerfassungSchema.parse({ titel: 'Rückruf Meier' })).toEqual({ + titel: 'Rückruf Meier', + typ: 'AUFGABE', + }) + }) + + it('kennt auch Ideen', () => { + expect( + schnellerfassungSchema.parse({ titel: 'Blog aufsetzen', typ: 'IDEE' }).typ, + ).toBe('IDEE') + }) + + it('lässt keine anderen Typen zu', () => { + expect( + schnellerfassungSchema.safeParse({ titel: 'Etwas', typ: 'MEILENSTEIN' }).success, + ).toBe(false) + }) +}) + +describe('projektSchema', () => { + it('nimmt ein Projekt an', () => { + const ergebnis = projektSchema.parse({ + name: 'Website neu', + beschreibung: '', + status: 'AKTIV', + bereichId: '', + zielAm: '', + }) + expect(ergebnis.name).toBe('Website neu') + expect(ergebnis.beschreibung).toBeNull() + expect(ergebnis.zielAm).toBeNull() + }) + + it('weist einen zu kurzen Namen ab', () => { + expect( + projektSchema.safeParse({ name: 'X', beschreibung: '', status: 'AKTIV' }).success, + ).toBe(false) + }) +}) diff --git a/src/lib/aufgabe.ts b/src/lib/aufgabe.ts new file mode 100644 index 0000000..7114b77 --- /dev/null +++ b/src/lib/aufgabe.ts @@ -0,0 +1,290 @@ +import { z } from 'zod' +import type { + AufgabenStatus, + AufgabenTyp, + ProjektStatus, + Quelle, +} from '@/generated/prisma/enums' +import { tageDazwischen, tagesBeginn } from '@/lib/zeit' + +/** + * Regeln rund um Aufgaben. Reine Logik: keine Datenbank, keine Systemzeit — + * der Zeitpunkt kommt immer als Parameter herein. + */ + +export const PRIORITAET_MIN = 1 +export const PRIORITAET_MAX = 5 +/** Ab hier ist eine Aufgabe zu groß für einen Zeitblock am Stück. */ +export const AUFWAND_MAX_MINUTEN = 8 * 60 + +export const TYP_NAME: Record = { + AUFGABE: 'Aufgabe', + IDEE: 'Idee', + ARBEITSPAKET: 'Arbeitspaket', + MEILENSTEIN: 'Meilenstein', + SPIEGEL: 'Spiegel', +} + +export const STATUS_NAME: Record = { + OFFEN: 'Offen', + GEPLANT: 'Geplant', + WARTET: 'Wartet', + ERLEDIGT: 'Erledigt', + VERWORFEN: 'Verworfen', +} + +export const PROJEKT_STATUS_NAME: Record = { + AKTIV: 'Aktiv', + PAUSIERT: 'Pausiert', + ABGESCHLOSSEN: 'Abgeschlossen', + VERWORFEN: 'Verworfen', +} + +export const QUELLE_NAME: Record = { + MANUELL: 'Von Hand', + ZAMMAD: 'Zammad', + WARTUNGSPLANER: 'Wartungsplaner', + TELEGRAM: 'Telegram', + SPIEGEL: 'Spiegel', + MAIL: 'E-Mail', + API: 'API', + MCP: 'MCP', +} + +export const PRIORITAET_NAME: Record = { + 1: 'Sofort', + 2: 'Hoch', + 3: 'Normal', + 4: 'Niedrig', + 5: 'Irgendwann', +} + +/** Status, in denen eine Aufgabe noch Arbeit bedeutet. */ +export const OFFENE_STATUS: readonly AufgabenStatus[] = ['OFFEN', 'GEPLANT', 'WARTET'] + +/** Status, in denen nichts mehr zu tun ist. */ +export const ABGESCHLOSSENE_STATUS: readonly AufgabenStatus[] = ['ERLEDIGT', 'VERWORFEN'] + +export function istOffen(status: AufgabenStatus): boolean { + return OFFENE_STATUS.includes(status) +} + +export function istAbgeschlossen(status: AufgabenStatus): boolean { + return ABGESCHLOSSENE_STATUS.includes(status) +} + +/** + * Erlaubte Statuswechsel. + * + * Aus einem abgeschlossenen Zustand geht es nur zurueck nach OFFEN – wieder + * aufgenommen wird immer am Anfang, nicht mitten im Ablauf. + */ +const UEBERGAENGE: Record = { + OFFEN: ['GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], + GEPLANT: ['OFFEN', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], + WARTET: ['OFFEN', 'GEPLANT', 'ERLEDIGT', 'VERWORFEN'], + ERLEDIGT: ['OFFEN'], + VERWORFEN: ['OFFEN'], +} + +export function uebergangErlaubt(von: AufgabenStatus, nach: AufgabenStatus): boolean { + if (von === nach) return true + return UEBERGAENGE[von].includes(nach) +} + +export function moeglicheUebergaenge(von: AufgabenStatus): readonly AufgabenStatus[] { + return UEBERGAENGE[von] +} + +// --- Faelligkeit ----------------------------------------------------------- + +export type Faelligkeit = + | { art: 'ohne' } + | { art: 'ueberfaellig'; tage: number } + | { art: 'heute' } + | { art: 'morgen' } + | { art: 'diese-woche'; tage: number } + | { art: 'spaeter'; tage: number } + +/** + * Ordnet die Faelligkeit relativ zum aktuellen Tag ein. Gerechnet wird in + * Kalendertagen in Europe/Berlin, nicht in 24-Stunden-Schritten: was heute um + * 23:00 faellig ist, ist heute faellig und nicht "in 0,5 Tagen". + */ +export function faelligkeitEinordnen( + faelligAm: Date | null | undefined, + jetzt: Date, +): Faelligkeit { + if (!faelligAm) return { art: 'ohne' } + + const tage = tageDazwischen(jetzt, faelligAm) + if (tage < 0) return { art: 'ueberfaellig', tage: Math.abs(tage) } + if (tage === 0) return { art: 'heute' } + if (tage === 1) return { art: 'morgen' } + if (tage <= 7) return { art: 'diese-woche', tage } + return { art: 'spaeter', tage } +} + +/** Kurztext fuer die Oberflaeche. */ +export function faelligkeitText(faelligkeit: Faelligkeit): string { + switch (faelligkeit.art) { + case 'ohne': + return 'ohne Termin' + case 'ueberfaellig': + return faelligkeit.tage === 1 + ? 'seit gestern überfällig' + : `seit ${faelligkeit.tage} Tagen überfällig` + case 'heute': + return 'heute fällig' + case 'morgen': + return 'morgen fällig' + case 'diese-woche': + return `in ${faelligkeit.tage} Tagen fällig` + case 'spaeter': + return `in ${faelligkeit.tage} Tagen fällig` + } +} + +export function istUeberfaellig( + faelligAm: Date | null | undefined, + status: AufgabenStatus, + jetzt: Date, +): boolean { + if (!faelligAm || istAbgeschlossen(status)) return false + return tagesBeginn(faelligAm).getTime() < tagesBeginn(jetzt).getTime() +} + +// --- Sortierung ------------------------------------------------------------ + +export type SortierbareAufgabe = { + status: AufgabenStatus + prioritaet: number + faelligAm: Date | null + titel: string +} + +/** + * Reihenfolge in der Liste: erst was noch offen ist, darin das Dringendste + * zuerst. Ohne Termin heisst nicht unwichtig – solche Aufgaben landen nach + * den terminierten, aber vor den abgeschlossenen. + */ +export function nachDringlichkeit(a: SortierbareAufgabe, b: SortierbareAufgabe): number { + const aOffen = istOffen(a.status) + const bOffen = istOffen(b.status) + if (aOffen !== bOffen) return aOffen ? -1 : 1 + + const aTermin = a.faelligAm?.getTime() ?? null + const bTermin = b.faelligAm?.getTime() ?? null + if (aTermin !== bTermin) { + if (aTermin === null) return 1 + if (bTermin === null) return -1 + return aTermin - bTermin + } + + if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet + + return a.titel.localeCompare(b.titel, 'de') +} + +// --- Fortschritt eines Projekts ------------------------------------------- + +export type Fortschritt = { + gesamt: number + erledigt: number + offen: number + /** 0 bis 100, gerundet. Ohne Aufgaben null. */ + prozent: number +} + +export function fortschrittBerechnen( + aufgaben: readonly { status: AufgabenStatus }[], +): Fortschritt { + // Verworfenes zaehlt gar nicht mit – sonst sieht ein Projekt fertig aus, + // weil die Haelfte gestrichen wurde. + const zaehlende = aufgaben.filter((a) => a.status !== 'VERWORFEN') + const gesamt = zaehlende.length + const erledigt = zaehlende.filter((a) => a.status === 'ERLEDIGT').length + + return { + gesamt, + erledigt, + offen: gesamt - erledigt, + prozent: gesamt === 0 ? 0 : Math.round((erledigt / gesamt) * 100), + } +} + +/** Summe des geschaetzten Aufwands; nicht geschaetzte Aufgaben zaehlen null. */ +export function aufwandSumme( + aufgaben: readonly { aufwandMinuten: number | null }[], +): number { + return aufgaben.reduce((summe, a) => summe + (a.aufwandMinuten ?? 0), 0) +} + +// --- Eingaben -------------------------------------------------------------- + +const leerZuNull = (wert: unknown) => + typeof wert === 'string' && wert.trim() === '' ? null : wert + +export const aufgabeSchema = z.object({ + titel: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(200, 'Höchstens 200 Zeichen.'), + notiz: z.preprocess(leerZuNull, z.string().trim().max(5000).nullable().default(null)), + typ: z.enum(['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL']), + status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']), + prioritaet: z.coerce + .number() + .int() + .min(PRIORITAET_MIN, 'Priorität liegt zwischen 1 und 5.') + .max(PRIORITAET_MAX, 'Priorität liegt zwischen 1 und 5.'), + aufwandMinuten: z.preprocess( + leerZuNull, + z.coerce + .number() + .int('Bitte ganze Minuten angeben.') + .min(1, 'Mindestens eine Minute.') + .max(AUFWAND_MAX_MINUTEN, 'Mehr als acht Stunden gehört in mehrere Aufgaben.') + .nullable() + .default(null), + ), + faelligAm: z.preprocess( + leerZuNull, + z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), + ), + bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), + projektId: z.preprocess(leerZuNull, z.string().nullable().default(null)), +}) + +export type AufgabeEingabe = z.infer + +/** Schnellerfassung: nur ein Titel, alles andere bekommt Standardwerte. */ +export const schnellerfassungSchema = z.object({ + titel: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(200, 'Höchstens 200 Zeichen.'), + typ: z.enum(['AUFGABE', 'IDEE']).default('AUFGABE'), +}) + +export const projektSchema = z.object({ + name: z + .string() + .trim() + .min(2, 'Mindestens zwei Zeichen.') + .max(80, 'Höchstens 80 Zeichen.'), + beschreibung: z.preprocess( + leerZuNull, + z.string().trim().max(2000).nullable().default(null), + ), + status: z.enum(['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN']), + bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), + zielAm: z.preprocess( + leerZuNull, + z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), + ), +}) + +export type ProjektEingabe = z.infer