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>
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|