feat(zeit): Zeitrechnung und Anzeigeformate in Europe/Berlin
Tagesgrenzen, Wochenbeginn am Montag und Wochentagsindex passend zu den Kapazitätsfeldern eines Bereichs. Alle Funktionen bekommen den Zeitpunkt als Parameter, lesen also nie selbst die Uhr. Die Tests laufen bewusst in UTC, damit sofort auffällt, wenn irgendwo die Ortszeit des Servers durchschlägt; sie decken beide Zeitzonen-Halbjahre und die Umstellungswoche ab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e2b00f4a57
commit
19ca0826e6
@@ -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))
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+123
@@ -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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user