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>
291 lines
8.6 KiB
TypeScript
291 lines
8.6 KiB
TypeScript
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>
|