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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f8f505126
commit
e4d3547457
@@ -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> = {}): Fund {
|
||||||
|
return { ref: 'T-1', titel: 'Drucker klemmt', ...teile }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestand(teile: Partial<Bestand> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user