From e4d3547457e4ca835243a1e86b526168abed4a2a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 23:03:33 +0200 Subject: [PATCH] feat: Aufnahmeregeln fuer Integrationen als reine Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was aus einem Fund wird, entscheidet fuer alle Integrationen dieselbe Stelle: anlegen, aktualisieren, schliessen oder nichts tun. Gefuehrt werden nur Titel, Notiz, Verweis und Faelligkeit. Bereich, Projekt, Prioritaet und geschaetzter Aufwand gehoeren der Planung — wer ein Ticket einsortiert und schaetzt, will nicht, dass der naechste Lauf das wieder wegraeumt. Dazu die Regeln des Spiegels: Aufbau der Sendung und ihre Frist. Ohne Frist liesse sich ein abgefangenes Paket beliebig oft erneut einspielen. 21 Tests. Co-Authored-By: Claude Opus 5 --- src/lib/integration.test.ts | 188 ++++++++++++++++++++++++++++++++++++ src/lib/integration.ts | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/lib/integration.test.ts create mode 100644 src/lib/integration.ts diff --git a/src/lib/integration.test.ts b/src/lib/integration.test.ts new file mode 100644 index 0000000..0242415 --- /dev/null +++ b/src/lib/integration.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from 'vitest' +import { + alsTitel, + sendungFrisch, + spiegelSendung, + TITEL_MAX, + uebernahmeEntscheiden, + type Bestand, + type Fund, +} from '@/lib/integration' + +function fund(teile: Partial = {}): Fund { + return { ref: 'T-1', titel: 'Drucker klemmt', ...teile } +} + +function bestand(teile: Partial = {}): Bestand { + return { + titel: 'Drucker klemmt', + notiz: null, + quelleUrl: null, + faelligAm: null, + prioritaet: 3, + aufwandMinuten: null, + erledigt: false, + ...teile, + } +} + +describe('uebernahmeEntscheiden', () => { + test('legt an, was es hier noch nicht gibt', () => { + expect(uebernahmeEntscheiden(fund(), null)).toEqual({ tun: 'anlegen' }) + }) + + test('legt nichts an, was drüben schon erledigt ist', () => { + expect(uebernahmeEntscheiden(fund({ erledigt: true }), null)).toEqual({ + tun: 'nichts', + grund: 'schon-erledigt', + }) + }) + + test('erkennt den unveränderten Fall', () => { + expect(uebernahmeEntscheiden(fund(), bestand())).toEqual({ + tun: 'nichts', + grund: 'unveraendert', + }) + }) + + test('übernimmt einen geänderten Titel', () => { + const entscheidung = uebernahmeEntscheiden( + fund({ titel: 'Drucker klemmt weiterhin' }), + bestand(), + ) + expect(entscheidung).toEqual({ + tun: 'aktualisieren', + felder: { titel: 'Drucker klemmt weiterhin' }, + }) + }) + + test('übernimmt eine neue Fälligkeit', () => { + const faelligAm = new Date('2026-04-01T00:00:00.000Z') + expect(uebernahmeEntscheiden(fund({ faelligAm }), bestand())).toEqual({ + tun: 'aktualisieren', + felder: { faelligAm }, + }) + }) + + test('vergleicht Datumswerte nach Zeitpunkt, nicht nach Objekt', () => { + const faelligAm = new Date('2026-04-01T00:00:00.000Z') + const entscheidung = uebernahmeEntscheiden( + fund({ faelligAm: new Date(faelligAm.getTime()) }), + bestand({ faelligAm }), + ) + expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' }) + }) + + test('rührt Priorität und Aufwand nicht an', () => { + // Beides wird hier geschätzt, nicht drüben. Ein Abgleich, der die + // Schätzung wegräumt, macht die Wochenplanung kaputt. + const entscheidung = uebernahmeEntscheiden( + fund({ prioritaet: 1, aufwandMinuten: 30 }), + bestand({ prioritaet: 4, aufwandMinuten: 90 }), + ) + expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' }) + }) + + test('schließt, was drüben erledigt wurde', () => { + expect(uebernahmeEntscheiden(fund({ erledigt: true }), bestand())).toEqual({ + tun: 'schliessen', + }) + }) + + test('schließt nicht zweimal', () => { + expect( + uebernahmeEntscheiden(fund({ erledigt: true }), bestand({ erledigt: true })), + ).toEqual({ tun: 'nichts', grund: 'schon-erledigt' }) + }) + + test('holt eine hier erledigte Aufgabe zurück, wenn sie drüben wieder offen ist', () => { + const entscheidung = uebernahmeEntscheiden(fund(), bestand({ erledigt: true })) + // Das Ursprungssystem führt den Zustand; wer dort neu öffnet, meint es so. + expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' }) + }) +}) + +describe('alsTitel', () => { + test('lässt kurze Texte in Ruhe', () => { + expect(alsTitel('Angebot schreiben')).toEqual({ + titel: 'Angebot schreiben', + rest: null, + }) + }) + + test('macht aus Zeilenumbrüchen Leerzeichen', () => { + expect(alsTitel('Angebot\n\nschreiben ')).toEqual({ + titel: 'Angebot schreiben', + rest: null, + }) + }) + + test('kürzt lange Texte und hebt den Rest auf', () => { + const lang = `${'wort '.repeat(60)}ende` + const { titel, rest } = alsTitel(lang) + expect(titel.length).toBeLessThanOrEqual(TITEL_MAX + 1) + expect(titel.endsWith('…')).toBe(true) + expect(rest).not.toBeNull() + expect(rest?.endsWith('ende')).toBe(true) + }) + + test('trennt nicht mitten im Wort, wenn eine Fuge in der Nähe ist', () => { + const lang = `${'a'.repeat(60)} ${'b'.repeat(80)}` + const { titel } = alsTitel(lang) + expect(titel).toBe(`${'a'.repeat(60)}…`) + }) + + test('trennt hart, wenn es keine Fuge gibt', () => { + const { titel } = alsTitel('x'.repeat(200)) + expect(titel).toBe(`${'x'.repeat(TITEL_MAX)}…`) + }) +}) + +describe('sendungFrisch', () => { + const jetzt = new Date('2026-03-17T08:20:00.000Z') + + test('nimmt an, was gerade erst losgeschickt wurde', () => { + expect(sendungFrisch(new Date('2026-03-17T08:18:00.000Z'), jetzt)).toBe(true) + }) + + test('lehnt ein altes Paket ab', () => { + expect(sendungFrisch(new Date('2026-03-17T08:10:00.000Z'), jetzt)).toBe(false) + }) + + test('lehnt auch ein Paket aus der Zukunft ab', () => { + // Eine verstellte Uhr auf der Gegenseite ist genauso verdächtig. + expect(sendungFrisch(new Date('2026-03-17T08:40:00.000Z'), jetzt)).toBe(false) + }) +}) + +describe('spiegelSendung', () => { + test('nimmt ein vollständiges Paket an', () => { + const gelesen = spiegelSendung.safeParse({ + gesendetAm: '2026-03-17T08:20:00.000Z', + belegungen: [ + { + ref: 'zb-1', + beginn: '2026-03-17T09:00:00.000Z', + ende: '2026-03-17T10:00:00.000Z', + }, + ], + aufgaben: [{ ref: 'a-1', titel: 'Angebot schreiben' }], + }) + expect(gelesen.success).toBe(true) + expect(gelesen.success && gelesen.data.aufgaben[0]?.erledigt).toBe(false) + }) + + test('weist ein Paket ohne Zeitstempel ab', () => { + const gelesen = spiegelSendung.safeParse({ belegungen: [], aufgaben: [] }) + expect(gelesen.success).toBe(false) + }) + + test('weist eine Belegung mit kaputtem Datum ab', () => { + const gelesen = spiegelSendung.safeParse({ + gesendetAm: '2026-03-17T08:20:00.000Z', + belegungen: [{ ref: 'zb-1', beginn: 'morgen', ende: 'später' }], + aufgaben: [], + }) + expect(gelesen.success).toBe(false) + }) +}) diff --git a/src/lib/integration.ts b/src/lib/integration.ts new file mode 100644 index 0000000..858b183 --- /dev/null +++ b/src/lib/integration.ts @@ -0,0 +1,177 @@ +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 +}