feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte

Aufgabe mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft;
Projekt als eigene Klammer darum.

Zwei Entwurfsentscheidungen:
- Die Inbox ist kein eigener Zustand, sondern schlicht eine Aufgabe ohne
  Bereich. Einsortieren heißt: einen Bereich setzen. Ein zusätzliches
  Feld könnte mit der Zuordnung auseinanderlaufen.
- Projekt ist ein eigenes Modell, kein Aufgabentyp. Ein Projekt hat
  selbst weder Aufwand noch Fälligkeit, sondern leitet beides aus dem
  ab, was drinsteckt. ARBEITSPAKET und MEILENSTEIN sind Typen der
  Aufgaben *innerhalb* eines Projekts.

@@unique([quelle, quelleRef]) ist die Grundlage für die Integrationen ab
M6: sie upserten darüber, statt zu doppeln. Von Hand erfasste Aufgaben
tragen quelleRef = null; PostgreSQL sieht NULL-Werte als verschieden an,
sie behindern sich also nicht.

Verworfene Aufgaben zählen bewusst gar nicht in den Fortschritt eines
Projekts — sonst sähe es fertig aus, weil die Hälfte gestrichen wurde.

Fälligkeit wird in Kalendertagen in Europe/Berlin gerechnet, nicht in
24-Stunden-Schritten: was heute um 23:00 fällig ist, ist heute fällig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 16:17:41 +02:00
co-authored by Claude Opus 5
parent 704c490368
commit 63c3c15a3c
6 changed files with 1112 additions and 37 deletions
@@ -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;
+106
View File
@@ -121,10 +121,116 @@ model Bereich {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
aufgaben Aufgabe[]
projekte Projekt[]
@@index([aktiv, reihenfolge]) @@index([aktiv, reihenfolge])
@@map("bereich") @@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. /// Unveraenderlicher Nachweis jeder schreibenden Aktion.
model AuditEintrag { model AuditEintrag {
id String @id @default(cuid()) id String @id @default(cuid())
+119
View File
@@ -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> = {}): 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')
})
})
+96
View File
@@ -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 <input type="date">.
* 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'
}
+384
View File
@@ -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>): 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)
})
})
+290
View File
@@ -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<AufgabenTyp, string> = {
AUFGABE: 'Aufgabe',
IDEE: 'Idee',
ARBEITSPAKET: 'Arbeitspaket',
MEILENSTEIN: 'Meilenstein',
SPIEGEL: 'Spiegel',
}
export const STATUS_NAME: Record<AufgabenStatus, string> = {
OFFEN: 'Offen',
GEPLANT: 'Geplant',
WARTET: 'Wartet',
ERLEDIGT: 'Erledigt',
VERWORFEN: 'Verworfen',
}
export const PROJEKT_STATUS_NAME: Record<ProjektStatus, string> = {
AKTIV: 'Aktiv',
PAUSIERT: 'Pausiert',
ABGESCHLOSSEN: 'Abgeschlossen',
VERWORFEN: 'Verworfen',
}
export const QUELLE_NAME: Record<Quelle, string> = {
MANUELL: 'Von Hand',
ZAMMAD: 'Zammad',
WARTUNGSPLANER: 'Wartungsplaner',
TELEGRAM: 'Telegram',
SPIEGEL: 'Spiegel',
MAIL: 'E-Mail',
API: 'API',
MCP: 'MCP',
}
export const PRIORITAET_NAME: Record<number, string> = {
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<AufgabenStatus, readonly AufgabenStatus[]> = {
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<typeof aufgabeSchema>
/** 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<typeof projektSchema>