diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..3b367b0 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** Klassennamen zusammenfuehren, spaetere Tailwind-Klassen gewinnen. */ +export function cn(...eingaben: ClassValue[]): string { + return twMerge(clsx(eingaben)) +} diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 0000000..3442a19 --- /dev/null +++ b/src/lib/version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { VERSION, versionAnzeige } from './version' + +describe('VERSION', () => { + it('kommt aus der package.json und sieht nach Semver aus', () => { + expect(VERSION).toMatch(/^\d+\.\d+\.\d+(?:-[\w.]+)?$/) + }) +}) + +describe('versionAnzeige', () => { + it('stellt ein v voran', () => { + expect(versionAnzeige('0.1.0')).toBe('v0.1.0') + }) +}) diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..e343e70 --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,9 @@ +import paket from '../../package.json' with { type: 'json' } + +/** Die Version steht ausschliesslich in package.json. */ +export const VERSION: string = paket.version + +/** Fuer die Fusszeile: "v0.1.0". */ +export function versionAnzeige(version: string = VERSION): string { + return `v${version}` +} diff --git a/src/lib/zeit.test.ts b/src/lib/zeit.test.ts new file mode 100644 index 0000000..b2493c0 --- /dev/null +++ b/src/lib/zeit.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import { + alsDatum, + alsDatumUhrzeit, + alsDauer, + alsTagesschluessel, + alsUhrzeit, + ausUhrzeit, + gleicherTag, + minutenSeitMitternacht, + tageDazwischen, + tagesBeginn, + tagesEnde, + wochenBeginn, + wochenTage, + wochentagIndex, +} from './zeit' + +// Winterzeit: Berlin ist UTC+1. Sommerzeit: UTC+2. +const winter = new Date('2026-01-15T12:00:00Z') +const sommer = new Date('2026-07-15T12:00:00Z') + +describe('tagesBeginn', () => { + it('schneidet in der Winterzeit bei 23:00 UTC des Vortags', () => { + expect(tagesBeginn(winter).toISOString()).toBe('2026-01-14T23:00:00.000Z') + }) + + it('schneidet in der Sommerzeit bei 22:00 UTC des Vortags', () => { + expect(tagesBeginn(sommer).toISOString()).toBe('2026-07-14T22:00:00.000Z') + }) + + it('ordnet 22:30 UTC im Winter noch demselben Berliner Tag zu', () => { + // 22:30 UTC = 23:30 Berlin, also derselbe Tag. + expect(tagesBeginn(new Date('2026-01-15T22:30:00Z')).toISOString()).toBe( + '2026-01-14T23:00:00.000Z', + ) + }) + + it('ordnet 23:30 UTC im Winter bereits dem Folgetag zu', () => { + // 23:30 UTC = 00:30 Berlin am 16. + expect(tagesBeginn(new Date('2026-01-15T23:30:00Z')).toISOString()).toBe( + '2026-01-15T23:00:00.000Z', + ) + }) +}) + +describe('tagesEnde', () => { + it('endet eine Millisekunde vor dem naechsten Tagesbeginn', () => { + expect(tagesEnde(winter).getTime()).toBe( + tagesBeginn(new Date('2026-01-16T12:00:00Z')).getTime() - 1, + ) + }) +}) + +describe('wochenBeginn', () => { + it('beginnt am Montag, nicht am Sonntag', () => { + // 2026-01-15 ist ein Donnerstag, der Montag davor ist der 12. + expect(alsTagesschluessel(wochenBeginn(winter))).toBe('2026-01-12') + }) + + it('bleibt am Montag selbst stehen', () => { + const montag = new Date('2026-01-12T09:00:00Z') + expect(alsTagesschluessel(wochenBeginn(montag))).toBe('2026-01-12') + }) + + it('rechnet den Sonntag zur ablaufenden Woche', () => { + const sonntag = new Date('2026-01-18T09:00:00Z') + expect(alsTagesschluessel(wochenBeginn(sonntag))).toBe('2026-01-12') + }) +}) + +describe('wochenTage', () => { + it('liefert sieben Tage von Montag bis Sonntag', () => { + const tage = wochenTage(winter).map((t) => alsTagesschluessel(t)) + expect(tage).toEqual([ + '2026-01-12', + '2026-01-13', + '2026-01-14', + '2026-01-15', + '2026-01-16', + '2026-01-17', + '2026-01-18', + ]) + }) + + it('haelt die sieben Tage auch ueber die Zeitumstellung hinweg', () => { + // Die Sommerzeit beginnt 2026 am 29. Maerz. + const tage = wochenTage(new Date('2026-03-25T12:00:00Z')).map((t) => + alsTagesschluessel(t), + ) + expect(tage).toEqual([ + '2026-03-23', + '2026-03-24', + '2026-03-25', + '2026-03-26', + '2026-03-27', + '2026-03-28', + '2026-03-29', + ]) + }) +}) + +describe('wochentagIndex', () => { + it('zaehlt Montag als 0 und Sonntag als 6', () => { + expect(wochentagIndex(new Date('2026-01-12T12:00:00Z'))).toBe(0) + expect(wochentagIndex(new Date('2026-01-18T12:00:00Z'))).toBe(6) + }) +}) + +describe('tageDazwischen', () => { + it('zaehlt Kalendertage, nicht 24-Stunden-Bloecke', () => { + const spaetAbends = new Date('2026-01-15T22:30:00Z') // 23:30 Berlin + const frueh = new Date('2026-01-16T06:00:00Z') // 07:00 Berlin am Folgetag + expect(tageDazwischen(spaetAbends, frueh)).toBe(1) + }) +}) + +describe('gleicherTag', () => { + it('erkennt denselben Berliner Tag trotz unterschiedlicher UTC-Tage', () => { + expect( + gleicherTag(new Date('2026-01-15T22:30:00Z'), new Date('2026-01-15T08:00:00Z')), + ).toBe(true) + }) + + it('trennt zwei Tage', () => { + expect(gleicherTag(winter, new Date('2026-01-16T12:00:00Z'))).toBe(false) + }) +}) + +describe('minutenSeitMitternacht', () => { + it('rechnet in Berliner Ortszeit', () => { + expect(minutenSeitMitternacht(winter)).toBe(13 * 60) // 12:00 UTC = 13:00 Berlin + expect(minutenSeitMitternacht(sommer)).toBe(14 * 60) // 12:00 UTC = 14:00 Berlin + }) +}) + +describe('alsUhrzeit und ausUhrzeit', () => { + it('wandelt Minuten in eine Uhrzeit', () => { + expect(alsUhrzeit(0)).toBe('00:00') + expect(alsUhrzeit(480)).toBe('08:00') + expect(alsUhrzeit(1020)).toBe('17:00') + expect(alsUhrzeit(1439)).toBe('23:59') + }) + + it('faengt Werte ausserhalb des Tages ab', () => { + expect(alsUhrzeit(1440)).toBe('00:00') + expect(alsUhrzeit(-60)).toBe('23:00') + }) + + it('liest eine Uhrzeit zurueck', () => { + expect(ausUhrzeit('08:00')).toBe(480) + expect(ausUhrzeit('8:05')).toBe(485) + expect(ausUhrzeit(' 23:59 ')).toBe(1439) + }) + + it('weist Unsinn ab', () => { + expect(ausUhrzeit('24:00')).toBeNull() + expect(ausUhrzeit('08:60')).toBeNull() + expect(ausUhrzeit('acht')).toBeNull() + expect(ausUhrzeit('')).toBeNull() + }) + + it('ist in beide Richtungen stimmig', () => { + for (const minuten of [0, 1, 59, 60, 480, 719, 720, 1439]) { + expect(ausUhrzeit(alsUhrzeit(minuten))).toBe(minuten) + } + }) +}) + +describe('alsDauer', () => { + it('schreibt Minuten, Stunden und beides', () => { + expect(alsDauer(45)).toBe('45 min') + expect(alsDauer(60)).toBe('1 h') + expect(alsDauer(90)).toBe('1 h 30 min') + expect(alsDauer(0)).toBe('0 min') + }) + + it('rundet und bleibt bei Unsinn bei null', () => { + expect(alsDauer(89.6)).toBe('1 h 30 min') + expect(alsDauer(-30)).toBe('0 min') + }) +}) + +describe('Anzeigeformate', () => { + it('schreibt das Datum deutsch mit Wochentag', () => { + expect(alsDatum(winter)).toBe('Do., 15.01.2026') + }) + + it('schreibt Datum und Uhrzeit in Berliner Zeit', () => { + expect(alsDatumUhrzeit(sommer)).toBe('15.07.2026, 14:00') + }) + + it('liefert einen sortierbaren Tagesschluessel', () => { + expect(alsTagesschluessel(sommer)).toBe('2026-07-15') + }) +}) diff --git a/src/lib/zeit.ts b/src/lib/zeit.ts new file mode 100644 index 0000000..4d196de --- /dev/null +++ b/src/lib/zeit.ts @@ -0,0 +1,123 @@ +/** + * Zeitrechnung des Planers. + * + * Alle Anzeigen und Tagesgrenzen gelten in Europe/Berlin, unabhaengig davon, + * in welcher Zeitzone der Server laeuft. Reine Logik: die aktuelle Zeit wird + * immer als Parameter hereingereicht, nie hier gelesen. + */ +import { TZDate } from '@date-fns/tz' +import { + addDays, + differenceInCalendarDays, + endOfDay, + format, + startOfDay, + startOfWeek, +} from 'date-fns' +import { de } from 'date-fns/locale' + +export const ZEITZONE = 'Europe/Berlin' + +/** Dasselbe Zeitpunkt, betrachtet in der Zeitzone des Planers. */ +export function inZeitzone(zeitpunkt: Date, zeitzone: string = ZEITZONE): TZDate { + return new TZDate(zeitpunkt, zeitzone) +} + +/** 00:00:00 des Tages, in dem der Zeitpunkt in Europe/Berlin liegt. */ +export function tagesBeginn(zeitpunkt: Date, zeitzone: string = ZEITZONE): Date { + return new Date(startOfDay(inZeitzone(zeitpunkt, zeitzone)).getTime()) +} + +/** 23:59:59.999 desselben Tages. */ +export function tagesEnde(zeitpunkt: Date, zeitzone: string = ZEITZONE): Date { + return new Date(endOfDay(inZeitzone(zeitpunkt, zeitzone)).getTime()) +} + +/** Montag 00:00 der Woche, in der der Zeitpunkt liegt. */ +export function wochenBeginn(zeitpunkt: Date, zeitzone: string = ZEITZONE): Date { + return new Date( + startOfWeek(inZeitzone(zeitpunkt, zeitzone), { weekStartsOn: 1 }).getTime(), + ) +} + +/** Die sieben Tagesanfaenge der Woche, Montag zuerst. */ +export function wochenTage(zeitpunkt: Date, zeitzone: string = ZEITZONE): Date[] { + const montag = inZeitzone(wochenBeginn(zeitpunkt, zeitzone), zeitzone) + return Array.from({ length: 7 }, (_, i) => new Date(addDays(montag, i).getTime())) +} + +/** + * Wochentag als Index 0 = Montag bis 6 = Sonntag. Die Kapazitaetsfelder eines + * Bereichs stehen in dieser Reihenfolge. + */ +export function wochentagIndex(zeitpunkt: Date, zeitzone: string = ZEITZONE): number { + return (inZeitzone(zeitpunkt, zeitzone).getDay() + 6) % 7 +} + +/** Ganze Kalendertage zwischen zwei Zeitpunkten, in Europe/Berlin gezaehlt. */ +export function tageDazwischen( + von: Date, + bis: Date, + zeitzone: string = ZEITZONE, +): number { + return differenceInCalendarDays(inZeitzone(bis, zeitzone), inZeitzone(von, zeitzone)) +} + +/** Liegen beide Zeitpunkte am selben Kalendertag? */ +export function gleicherTag(a: Date, b: Date, zeitzone: string = ZEITZONE): boolean { + return tagesBeginn(a, zeitzone).getTime() === tagesBeginn(b, zeitzone).getTime() +} + +/** Minuten seit Mitternacht, z. B. fuer das Arbeitsfenster eines Bereichs. */ +export function minutenSeitMitternacht( + zeitpunkt: Date, + zeitzone: string = ZEITZONE, +): number { + const lokal = inZeitzone(zeitpunkt, zeitzone) + return lokal.getHours() * 60 + lokal.getMinutes() +} + +/** "08:00" aus 480. */ +export function alsUhrzeit(minutenSeitMitternacht: number): string { + const gesamt = ((minutenSeitMitternacht % 1440) + 1440) % 1440 + const stunden = Math.floor(gesamt / 60) + const minuten = gesamt % 60 + return `${String(stunden).padStart(2, '0')}:${String(minuten).padStart(2, '0')}` +} + +/** 480 aus "08:00". Gibt null zurueck, wenn die Eingabe keine Uhrzeit ist. */ +export function ausUhrzeit(text: string): number | null { + const treffer = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(text.trim()) + if (!treffer) return null + return Number(treffer[1]) * 60 + Number(treffer[2]) +} + +/** "1 h 30 min", "45 min", "2 h". Fuer Aufwaende und Kapazitaeten. */ +export function alsDauer(minuten: number): string { + const gerundet = Math.max(0, Math.round(minuten)) + const stunden = Math.floor(gerundet / 60) + const rest = gerundet % 60 + if (stunden === 0) return `${rest} min` + if (rest === 0) return `${stunden} h` + return `${stunden} h ${rest} min` +} + +/** "Mo., 10.09.2026" */ +export function alsDatum(zeitpunkt: Date, zeitzone: string = ZEITZONE): string { + return format(inZeitzone(zeitpunkt, zeitzone), 'EEE, dd.MM.yyyy', { locale: de }) +} + +/** "10.09.2026, 14:30" */ +export function alsDatumUhrzeit(zeitpunkt: Date, zeitzone: string = ZEITZONE): string { + return format(inZeitzone(zeitpunkt, zeitzone), 'dd.MM.yyyy, HH:mm', { locale: de }) +} + +/** "14:30" */ +export function alsUhrzeitAnzeige(zeitpunkt: Date, zeitzone: string = ZEITZONE): string { + return format(inZeitzone(zeitpunkt, zeitzone), 'HH:mm', { locale: de }) +} + +/** ISO-Datum "2026-09-10" in Europe/Berlin, taugt als Schluessel je Tag. */ +export function alsTagesschluessel(zeitpunkt: Date, zeitzone: string = ZEITZONE): string { + return format(inZeitzone(zeitpunkt, zeitzone), 'yyyy-MM-dd') +}