Files
planer/src/lib/integration.ts
T
Florian WildeandClaude Opus 5 e4d3547457 feat: Aufnahmeregeln fuer Integrationen als reine Logik
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 <noreply@anthropic.com>
2026-09-11 23:03:33 +02:00

178 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Bestand> }
| { 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<Bestand> = {}
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<typeof spiegelSendung>
/**
* 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
}