import { z } from 'zod' import type { AufgabenStatus, AufgabenTyp, ProjektStatus, Quelle, } from '@/generated/prisma/enums' import { tageDazwischen, tagesBeginn } from '@/lib/zeit' /** * Regeln rund um Aufgaben. Reine Logik: keine Datenbank, keine Systemzeit — * der Zeitpunkt kommt immer als Parameter herein. */ export const PRIORITAET_MIN = 1 export const PRIORITAET_MAX = 5 /** Ab hier ist eine Aufgabe zu groß für einen Zeitblock am Stück. */ export const AUFWAND_MAX_MINUTEN = 8 * 60 export const TYP_NAME: Record = { AUFGABE: 'Aufgabe', IDEE: 'Idee', ARBEITSPAKET: 'Arbeitspaket', MEILENSTEIN: 'Meilenstein', SPIEGEL: 'Spiegel', } export const STATUS_NAME: Record = { OFFEN: 'Offen', GEPLANT: 'Geplant', WARTET: 'Wartet', ERLEDIGT: 'Erledigt', VERWORFEN: 'Verworfen', } export const PROJEKT_STATUS_NAME: Record = { AKTIV: 'Aktiv', PAUSIERT: 'Pausiert', ABGESCHLOSSEN: 'Abgeschlossen', VERWORFEN: 'Verworfen', } export const QUELLE_NAME: Record = { MANUELL: 'Von Hand', ZAMMAD: 'Zammad', WARTUNGSPLANER: 'Wartungsplaner', TELEGRAM: 'Telegram', SPIEGEL: 'Spiegel', MAIL: 'E-Mail', API: 'API', MCP: 'MCP', } export const PRIORITAET_NAME: Record = { 1: 'Sofort', 2: 'Hoch', 3: 'Normal', 4: 'Niedrig', 5: 'Irgendwann', } /** Status, in denen eine Aufgabe noch Arbeit bedeutet. */ export const OFFENE_STATUS: readonly AufgabenStatus[] = ['OFFEN', 'GEPLANT', 'WARTET'] /** Status, in denen nichts mehr zu tun ist. */ export const ABGESCHLOSSENE_STATUS: readonly AufgabenStatus[] = ['ERLEDIGT', 'VERWORFEN'] export function istOffen(status: AufgabenStatus): boolean { return OFFENE_STATUS.includes(status) } export function istAbgeschlossen(status: AufgabenStatus): boolean { return ABGESCHLOSSENE_STATUS.includes(status) } /** * Erlaubte Statuswechsel. * * Aus einem abgeschlossenen Zustand geht es nur zurueck nach OFFEN – wieder * aufgenommen wird immer am Anfang, nicht mitten im Ablauf. */ const UEBERGAENGE: Record = { OFFEN: ['GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], GEPLANT: ['OFFEN', 'WARTET', 'ERLEDIGT', 'VERWORFEN'], WARTET: ['OFFEN', 'GEPLANT', 'ERLEDIGT', 'VERWORFEN'], ERLEDIGT: ['OFFEN'], VERWORFEN: ['OFFEN'], } export function uebergangErlaubt(von: AufgabenStatus, nach: AufgabenStatus): boolean { if (von === nach) return true return UEBERGAENGE[von].includes(nach) } export function moeglicheUebergaenge(von: AufgabenStatus): readonly AufgabenStatus[] { return UEBERGAENGE[von] } // --- Faelligkeit ----------------------------------------------------------- export type Faelligkeit = | { art: 'ohne' } | { art: 'ueberfaellig'; tage: number } | { art: 'heute' } | { art: 'morgen' } | { art: 'diese-woche'; tage: number } | { art: 'spaeter'; tage: number } /** * Ordnet die Faelligkeit relativ zum aktuellen Tag ein. Gerechnet wird in * Kalendertagen in Europe/Berlin, nicht in 24-Stunden-Schritten: was heute um * 23:00 faellig ist, ist heute faellig und nicht "in 0,5 Tagen". */ export function faelligkeitEinordnen( faelligAm: Date | null | undefined, jetzt: Date, ): Faelligkeit { if (!faelligAm) return { art: 'ohne' } const tage = tageDazwischen(jetzt, faelligAm) if (tage < 0) return { art: 'ueberfaellig', tage: Math.abs(tage) } if (tage === 0) return { art: 'heute' } if (tage === 1) return { art: 'morgen' } if (tage <= 7) return { art: 'diese-woche', tage } return { art: 'spaeter', tage } } /** Kurztext fuer die Oberflaeche. */ export function faelligkeitText(faelligkeit: Faelligkeit): string { switch (faelligkeit.art) { case 'ohne': return 'ohne Termin' case 'ueberfaellig': return faelligkeit.tage === 1 ? 'seit gestern überfällig' : `seit ${faelligkeit.tage} Tagen überfällig` case 'heute': return 'heute fällig' case 'morgen': return 'morgen fällig' case 'diese-woche': return `in ${faelligkeit.tage} Tagen fällig` case 'spaeter': return `in ${faelligkeit.tage} Tagen fällig` } } export function istUeberfaellig( faelligAm: Date | null | undefined, status: AufgabenStatus, jetzt: Date, ): boolean { if (!faelligAm || istAbgeschlossen(status)) return false return tagesBeginn(faelligAm).getTime() < tagesBeginn(jetzt).getTime() } // --- Sortierung ------------------------------------------------------------ export type SortierbareAufgabe = { status: AufgabenStatus prioritaet: number faelligAm: Date | null titel: string } /** * Reihenfolge in der Liste: erst was noch offen ist, darin das Dringendste * zuerst. Ohne Termin heisst nicht unwichtig – solche Aufgaben landen nach * den terminierten, aber vor den abgeschlossenen. */ export function nachDringlichkeit(a: SortierbareAufgabe, b: SortierbareAufgabe): number { const aOffen = istOffen(a.status) const bOffen = istOffen(b.status) if (aOffen !== bOffen) return aOffen ? -1 : 1 const aTermin = a.faelligAm?.getTime() ?? null const bTermin = b.faelligAm?.getTime() ?? null if (aTermin !== bTermin) { if (aTermin === null) return 1 if (bTermin === null) return -1 return aTermin - bTermin } if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet return a.titel.localeCompare(b.titel, 'de') } // --- Fortschritt eines Projekts ------------------------------------------- export type Fortschritt = { gesamt: number erledigt: number offen: number /** 0 bis 100, gerundet. Ohne Aufgaben null. */ prozent: number } export function fortschrittBerechnen( aufgaben: readonly { status: AufgabenStatus }[], ): Fortschritt { // Verworfenes zaehlt gar nicht mit – sonst sieht ein Projekt fertig aus, // weil die Haelfte gestrichen wurde. const zaehlende = aufgaben.filter((a) => a.status !== 'VERWORFEN') const gesamt = zaehlende.length const erledigt = zaehlende.filter((a) => a.status === 'ERLEDIGT').length return { gesamt, erledigt, offen: gesamt - erledigt, prozent: gesamt === 0 ? 0 : Math.round((erledigt / gesamt) * 100), } } /** Summe des geschaetzten Aufwands; nicht geschaetzte Aufgaben zaehlen null. */ export function aufwandSumme( aufgaben: readonly { aufwandMinuten: number | null }[], ): number { return aufgaben.reduce((summe, a) => summe + (a.aufwandMinuten ?? 0), 0) } // --- Eingaben -------------------------------------------------------------- const leerZuNull = (wert: unknown) => typeof wert === 'string' && wert.trim() === '' ? null : wert export const aufgabeSchema = z.object({ titel: z .string() .trim() .min(2, 'Mindestens zwei Zeichen.') .max(200, 'Höchstens 200 Zeichen.'), notiz: z.preprocess(leerZuNull, z.string().trim().max(5000).nullable().default(null)), typ: z.enum(['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL']), status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']), prioritaet: z.coerce .number() .int() .min(PRIORITAET_MIN, 'Priorität liegt zwischen 1 und 5.') .max(PRIORITAET_MAX, 'Priorität liegt zwischen 1 und 5.'), aufwandMinuten: z.preprocess( leerZuNull, z.coerce .number() .int('Bitte ganze Minuten angeben.') .min(1, 'Mindestens eine Minute.') .max(AUFWAND_MAX_MINUTEN, 'Mehr als acht Stunden gehört in mehrere Aufgaben.') .nullable() .default(null), ), faelligAm: z.preprocess( leerZuNull, z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), ), bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), projektId: z.preprocess(leerZuNull, z.string().nullable().default(null)), }) export type AufgabeEingabe = z.infer /** Schnellerfassung: nur ein Titel, alles andere bekommt Standardwerte. */ export const schnellerfassungSchema = z.object({ titel: z .string() .trim() .min(2, 'Mindestens zwei Zeichen.') .max(200, 'Höchstens 200 Zeichen.'), typ: z.enum(['AUFGABE', 'IDEE']).default('AUFGABE'), }) export const projektSchema = z.object({ name: z .string() .trim() .min(2, 'Mindestens zwei Zeichen.') .max(80, 'Höchstens 80 Zeichen.'), beschreibung: z.preprocess( leerZuNull, z.string().trim().max(2000).nullable().default(null), ), status: z.enum(['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN']), bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)), zielAm: z.preprocess( leerZuNull, z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null), ), }) export type ProjektEingabe = z.infer