feat(zeitbloecke): Datenmodell und Schleifenschutz
Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht. Er lebt an zwei Orten — hier und beim Anbieter — und beide Seiten dürfen ihn verschieben. Daraus entsteht die Gefahr einer Endlosschleife: Die App schreibt, der Abgleich liest die eigene Änderung, hält sie für fremd, schreibt zurück, der Abgleich liest wieder. Der Ausweg ist ein Fingerabdruck über den Zeitraum, sha256(startISO|endeISO). Beim Schreiben merkt sich der Zeitblock, was er in den Kalender gelegt hat; kommt derselbe Fingerabdruck zurück, war es die eigene Änderung. Weicht er ab, hat jemand anderes verschoben — und dann gewinnt der Kalender. Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch aus harmlosen Gründen, und für die Planung zählt allein, wann der Block liegt. 30 Tests, darunter zwei, die den eigentlichen Zweck prüfen: dass es sich über mehrere Runden nicht hochschaukelt und nach einer übernommenen fremden Verschiebung wieder zur Ruhe kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd9df22a18
commit
5791047982
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RASTER_MINUTEN,
|
||||
ZEITBLOCK_MAX_MINUTEN,
|
||||
ZEITBLOCK_MIN_MINUTEN,
|
||||
abgleichEntscheiden,
|
||||
aufRaster,
|
||||
dauerMinuten,
|
||||
lagePruefen,
|
||||
mussSchreiben,
|
||||
ueberschneidetSich,
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
} from './zeitblock'
|
||||
|
||||
const beginn = new Date('2026-03-17T09:00:00Z')
|
||||
const ende = new Date('2026-03-17T10:00:00Z')
|
||||
|
||||
describe('zeitraumHash', () => {
|
||||
it('ist für denselben Zeitraum gleich', () => {
|
||||
expect(zeitraumHash(beginn, ende)).toBe(
|
||||
zeitraumHash(new Date(beginn), new Date(ende)),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn der Beginn wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn das Ende wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
|
||||
)
|
||||
})
|
||||
|
||||
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
|
||||
// Derselbe Augenblick, anders geschrieben.
|
||||
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
|
||||
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abgleichEntscheiden — der Schleifenschutz', () => {
|
||||
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
|
||||
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
|
||||
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
|
||||
})
|
||||
|
||||
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||
})
|
||||
|
||||
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
|
||||
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
|
||||
)
|
||||
|
||||
expect(entscheidung).toEqual({
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: verschobenerBeginn,
|
||||
ende: verschobenesEnde,
|
||||
})
|
||||
})
|
||||
|
||||
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{
|
||||
beginn: new Date('2026-03-17T11:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
},
|
||||
)
|
||||
expect(entscheidung.tun).toBe('uebernehmen')
|
||||
})
|
||||
|
||||
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
null,
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
|
||||
})
|
||||
|
||||
it('schaukelt sich über mehrere Runden nicht hoch', () => {
|
||||
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
|
||||
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
|
||||
for (let runde = 0; runde < 3; runde++) {
|
||||
const entscheidung = abgleichEntscheiden(stand, {
|
||||
beginn: stand.beginn,
|
||||
ende: stand.ende,
|
||||
})
|
||||
expect(entscheidung.tun).toBe('nichts')
|
||||
stand = { ...stand }
|
||||
}
|
||||
})
|
||||
|
||||
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
|
||||
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const neuesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
// Erste Runde: fremde Verschiebung wird übernommen.
|
||||
const erste = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(erste.tun).toBe('uebernehmen')
|
||||
|
||||
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
|
||||
// erkennt ihn wieder.
|
||||
const zweite = abgleichEntscheiden(
|
||||
{
|
||||
hash: zeitraumHash(neuerBeginn, neuesEnde),
|
||||
beginn: neuerBeginn,
|
||||
ende: neuesEnde,
|
||||
},
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(zweite.tun).toBe('nichts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mussSchreiben', () => {
|
||||
it('schreibt bei einer echten Verschiebung', () => {
|
||||
expect(
|
||||
mussSchreiben(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
new Date('2026-03-17T11:00:00Z'),
|
||||
new Date('2026-03-17T12:00:00Z'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
|
||||
// Etwa wenn nur der Aufgabentitel geändert wurde.
|
||||
expect(
|
||||
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('schreibt, solange noch nichts geschrieben wurde', () => {
|
||||
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dauerMinuten', () => {
|
||||
it('rechnet in Minuten', () => {
|
||||
expect(dauerMinuten(beginn, ende)).toBe(60)
|
||||
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufRaster', () => {
|
||||
it('rastet auf die nächste Viertelstunde ein', () => {
|
||||
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:00:00.000Z',
|
||||
)
|
||||
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:15:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
|
||||
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('lagePruefen', () => {
|
||||
it('nimmt eine gültige Lage an', () => {
|
||||
expect(lagePruefen(beginn, ende)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist ein Ende vor dem Beginn ab', () => {
|
||||
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
|
||||
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
|
||||
})
|
||||
|
||||
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
|
||||
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
|
||||
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
|
||||
|
||||
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
|
||||
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
|
||||
|
||||
const zuLang = new Date(
|
||||
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
|
||||
)
|
||||
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
|
||||
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ueberschneidetSich', () => {
|
||||
const block = { beginn, ende }
|
||||
|
||||
it('erkennt eine Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T09:30:00Z'),
|
||||
ende: new Date('2026-03-17T10:30:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt reines Aneinandergrenzen nicht', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: ende,
|
||||
ende: new Date('2026-03-17T11:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt vollständige Umschließung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt getrennte Zeiträume', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T14:00:00Z'),
|
||||
ende: new Date('2026-03-17T15:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeitblockSchema', () => {
|
||||
const gueltig = {
|
||||
aufgabeId: 'a1',
|
||||
kalenderkontoId: 'k1',
|
||||
beginn: '2026-03-17T09:00:00.000Z',
|
||||
ende: '2026-03-17T10:00:00.000Z',
|
||||
}
|
||||
|
||||
it('nimmt eine gültige Eingabe an', () => {
|
||||
const ergebnis = zeitblockSchema.parse(gueltig)
|
||||
expect(ergebnis.beginn).toBeInstanceOf(Date)
|
||||
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
|
||||
})
|
||||
|
||||
it('weist eine unmögliche Lage ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
|
||||
.success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('verlangt Aufgabe und Kalenderkonto', () => {
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Zeitblöcke und der Schleifenschutz.
|
||||
*
|
||||
* Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
|
||||
* Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||
* Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr
|
||||
* einer Endlosschleife:
|
||||
*
|
||||
* Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für
|
||||
* fremd → schreibt zurück → der Abgleich liest wieder → …
|
||||
*
|
||||
* Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt
|
||||
* sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten
|
||||
* Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es
|
||||
* passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt
|
||||
* der Kalender.
|
||||
*
|
||||
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fingerabdruck eines Zeitraums.
|
||||
*
|
||||
* Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
|
||||
* aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und
|
||||
* für die Planung zählt allein, wann der Block liegt.
|
||||
*/
|
||||
export function zeitraumHash(beginn: Date, ende: Date): string {
|
||||
return createHash('sha256')
|
||||
.update(`${beginn.toISOString()}|${ende.toISOString()}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/** Was beim Abgleich mit einem Zeitblock zu tun ist. */
|
||||
export type Abgleichentscheidung =
|
||||
| { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' }
|
||||
| { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date }
|
||||
| { tun: 'loesen'; grund: 'im-kalender-entfernt' }
|
||||
|
||||
export type ZeitblockStand = {
|
||||
/** Was zuletzt in den Kalender geschrieben wurde. */
|
||||
hash: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
}
|
||||
|
||||
export type KalenderStand = {
|
||||
beginn: Date
|
||||
ende: Date
|
||||
} | null
|
||||
|
||||
/**
|
||||
* Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll.
|
||||
*
|
||||
* Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer
|
||||
* das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so
|
||||
* — und merkt es sofort, wenn der Planer es wieder wegzieht.
|
||||
*/
|
||||
export function abgleichEntscheiden(
|
||||
zeitblock: ZeitblockStand,
|
||||
imKalender: KalenderStand,
|
||||
): Abgleichentscheidung {
|
||||
if (!imKalender) {
|
||||
// Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die
|
||||
// Aufgabe selbst bleibt bestehen.
|
||||
return { tun: 'loesen', grund: 'im-kalender-entfernt' }
|
||||
}
|
||||
|
||||
const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende)
|
||||
|
||||
// Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es
|
||||
// die eigene Änderung – nichts zu tun. Das bricht die Schleife.
|
||||
if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) {
|
||||
return { tun: 'nichts', grund: 'eigene-aenderung' }
|
||||
}
|
||||
|
||||
// Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts
|
||||
// zu tun – etwa beim allerersten Abgleich nach dem Anlegen.
|
||||
const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende)
|
||||
if (imKalenderHash === eigenerHash) {
|
||||
return { tun: 'nichts', grund: 'unveraendert' }
|
||||
}
|
||||
|
||||
return {
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: imKalender.beginn,
|
||||
ende: imKalender.ende,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss beim Speichern in den Kalender geschrieben werden?
|
||||
*
|
||||
* Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert,
|
||||
* bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu
|
||||
* behelligen.
|
||||
*/
|
||||
export function mussSchreiben(
|
||||
zeitblock: ZeitblockStand,
|
||||
neuerBeginn: Date,
|
||||
neuesEnde: Date,
|
||||
): boolean {
|
||||
return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde)
|
||||
}
|
||||
|
||||
// --- Lage und Dauer --------------------------------------------------------
|
||||
|
||||
export const ZEITBLOCK_MIN_MINUTEN = 15
|
||||
export const ZEITBLOCK_MAX_MINUTEN = 8 * 60
|
||||
/** Raster, in dem Zeitblöcke einrasten. */
|
||||
export const RASTER_MINUTEN = 15
|
||||
|
||||
export function dauerMinuten(beginn: Date, ende: Date): number {
|
||||
return Math.round((ende.getTime() - beginn.getTime()) / 60_000)
|
||||
}
|
||||
|
||||
/** Rundet einen Zeitpunkt auf das Raster ab. */
|
||||
export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date {
|
||||
const minuten = raster * 60_000
|
||||
return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten)
|
||||
}
|
||||
|
||||
export type LageFehler =
|
||||
'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null
|
||||
|
||||
/** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */
|
||||
export function lagePruefen(beginn: Date, ende: Date): LageFehler {
|
||||
if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn'
|
||||
|
||||
const dauer = dauerMinuten(beginn, ende)
|
||||
if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz'
|
||||
if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang'
|
||||
|
||||
if (
|
||||
beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 ||
|
||||
ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0
|
||||
) {
|
||||
return 'nicht-im-raster'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const LAGE_FEHLER_TEXT: Record<Exclude<LageFehler, null>, string> = {
|
||||
'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`,
|
||||
'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.',
|
||||
'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.',
|
||||
'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`,
|
||||
}
|
||||
|
||||
/** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */
|
||||
export function ueberschneidetSich(
|
||||
a: { beginn: Date; ende: Date },
|
||||
b: { beginn: Date; ende: Date },
|
||||
): boolean {
|
||||
return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime()
|
||||
}
|
||||
|
||||
// --- Eingaben --------------------------------------------------------------
|
||||
|
||||
export const zeitblockSchema = z
|
||||
.object({
|
||||
aufgabeId: z.string().min(1),
|
||||
kalenderkontoId: z.string().min(1),
|
||||
beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'),
|
||||
ende: z.coerce.date('Bitte ein gültiges Ende angeben.'),
|
||||
})
|
||||
.refine((w) => lagePruefen(w.beginn, w.ende) === null, {
|
||||
path: ['ende'],
|
||||
message: 'Die Lage des Zeitblocks stimmt nicht.',
|
||||
})
|
||||
|
||||
export type ZeitblockEingabe = z.infer<typeof zeitblockSchema>
|
||||
Reference in New Issue
Block a user