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:
co-authored by
Claude Opus 5
parent
704c490368
commit
63c3c15a3c
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user