import { z } from 'zod' /** * Gemeinsame Regeln für alle Integrationen. * * Der Kern jeder Integration ist derselbe: ein fremdes System liefert Zeug, * daraus wird eine Aufgabe, und beim nächsten Lauf darf dieselbe Sache keine * zweite Aufgabe ergeben. Was sich unterscheidet, ist allein das Abholen. * * Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit. */ /** Was eine Integration liefert, bevor daraus eine Aufgabe wird. */ export type Fund = { /** Kennung beim Ursprungssystem. Bleibt über die Lebensdauer gleich. */ ref: string titel: string notiz?: string | null url?: string | null faelligAm?: Date | null /** 1 = dringend, 5 = irgendwann. */ prioritaet?: number | null aufwandMinuten?: number | null /** Im Ursprungssystem abgeschlossen: die Aufgabe darf zu. */ erledigt?: boolean } /** * Was beim Übernehmen eines Fundes passieren soll. * * Der heikle Teil ist nicht das Anlegen, sondern das zweite Mal: Eine Aufgabe, * an der hier gearbeitet wurde, darf ein Abgleich nicht zurücksetzen. */ export type Uebernahme = | { tun: 'anlegen' } | { tun: 'aktualisieren'; felder: Partial } | { tun: 'schliessen' } | { tun: 'nichts'; grund: 'unveraendert' | 'von-hand-geaendert' | 'schon-erledigt' } /** Der Stand, wie er in der Datenbank liegt. */ export type Bestand = { titel: string notiz: string | null quelleUrl: string | null faelligAm: Date | null prioritaet: number aufwandMinuten: number | null erledigt: boolean } /** * Felder, die das Ursprungssystem führt — und nur diese. * * Bereich, Projekt und Zeitblöcke gehören der Planung und werden nie aus * einer Integration überschrieben. Wer ein Ticket einem Bereich zuordnet, * will nicht, dass der nächste Lauf das wieder wegräumt. */ const GEFUEHRT = ['titel', 'notiz', 'quelleUrl', 'faelligAm'] as const function gleich(a: unknown, b: unknown): boolean { if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() return a === b } export function uebernahmeEntscheiden(fund: Fund, bestand: Bestand | null): Uebernahme { if (!bestand) { // Ein Fund, der drüben schon erledigt ist, braucht hier nicht erst zu // entstehen, nur um sofort wieder zuzugehen. return fund.erledigt ? { tun: 'nichts', grund: 'schon-erledigt' } : { tun: 'anlegen' } } if (fund.erledigt) { return bestand.erledigt ? { tun: 'nichts', grund: 'schon-erledigt' } : { tun: 'schliessen' } } const neu: Partial = {} const gewuenscht: Bestand = { titel: fund.titel, notiz: fund.notiz ?? null, quelleUrl: fund.url ?? null, faelligAm: fund.faelligAm ?? null, prioritaet: bestand.prioritaet, aufwandMinuten: bestand.aufwandMinuten, erledigt: false, } for (const feld of GEFUEHRT) { if (!gleich(bestand[feld], gewuenscht[feld])) { // @ts-expect-error – der Index ist über GEFUEHRT eingegrenzt. neu[feld] = gewuenscht[feld] } } return Object.keys(neu).length === 0 ? { tun: 'nichts', grund: 'unveraendert' } : { tun: 'aktualisieren', felder: neu } } /** * Ab wann ein Fehlschlag gemeldet wird — dieselbe Schwelle wie beim * Kalenderabgleich. Ein Aussetzer ist keine Nachricht wert. */ export const INTEGRATION_FEHLER_AB_VERSUCH = 3 /** * Kürzt einen Text auf eine Länge, die als Aufgabentitel taugt. * * Fremde Systeme kennen keine Grenze; ein Titel über mehrere Zeilen macht * jede Liste unlesbar. Der Rest wandert in die Notiz, geht also nicht * verloren. */ export const TITEL_MAX = 120 export function alsTitel(text: string): { titel: string; rest: string | null } { const sauber = text.replace(/\s+/g, ' ').trim() if (sauber.length <= TITEL_MAX) return { titel: sauber, rest: null } // Lieber an einem Wort trennen als mitten hinein – aber nur, solange // dabei nicht mehr als die Haelfte des Titels wegfaellt. const schnitt = sauber.lastIndexOf(' ', TITEL_MAX) const bei = schnitt >= TITEL_MAX / 2 ? schnitt : TITEL_MAX return { titel: `${sauber.slice(0, bei).trimEnd()}…`, rest: sauber.slice(bei).trim() } } // --- Spiegel --------------------------------------------------------------- /** * Was von einer Instanz zur anderen geht. * * Belegungen tragen keinen Titel: Daheim soll sehen, *dass* die Zeit weg ist, * nicht woran gearbeitet wird. Aufgaben gehen nur mit, wenn sie ausdrücklich * dafür markiert sind. */ export const spiegelBelegung = z.object({ ref: z.string().min(1).max(64), beginn: z.iso.datetime(), ende: z.iso.datetime(), }) export const spiegelAufgabe = z.object({ ref: z.string().min(1).max(64), titel: z .string() .min(1) .max(TITEL_MAX + 1), faelligAm: z.iso.datetime().nullable().default(null), erledigt: z.boolean().default(false), }) export const spiegelSendung = z.object({ /** Wann der Sender das Paket geschnürt hat, für die Frische. */ gesendetAm: z.iso.datetime(), belegungen: z.array(spiegelBelegung).max(500), aufgaben: z.array(spiegelAufgabe).max(500), }) export type SpiegelSendung = z.infer /** * Wie alt eine Sendung höchstens sein darf. * * Ohne Frist ließe sich ein abgefangenes Paket beliebig oft erneut * einspielen. Fünf Minuten sind genug für eine träge Leitung und zu wenig * für ein Archiv. */ export const SENDUNG_FRIST_MINUTEN = 5 export function sendungFrisch( gesendetAm: Date, jetzt: Date, fristMinuten = SENDUNG_FRIST_MINUTEN, ): boolean { const abstandMinuten = Math.abs(jetzt.getTime() - gesendetAm.getTime()) / 60_000 return abstandMinuten <= fristMinuten }