Files
planer/src/lib/woche.ts
T
Florian WildeandClaude Opus 5 bcb4b8eb02 feat(woche): Rasterlogik für die Wochenansicht
Aus Zeitpunkt, Stundenfenster und Blöcken entsteht alles, was die Ansicht
braucht: Spalten ab Montag, Zeilen im Viertelstundenraster und die Lage
jedes Balkens darin. Reine Logik, 21 Tests.

Drei Feinheiten, die ohne Test leicht falsch geraten:
- Das Stundenfenster ist die Hülle über alle Arbeitsfenster, mit einer
  Stunde Luft. Ohne die Luft läge ein Block, der genau am Rand beginnt, an
  der Kante und ließe sich kaum greifen.
- Balken werden am Fensterrand abgeschnitten. Ein Termin von 6 bis 23 Uhr
  würde sonst das Raster sprengen.
- Sich überschneidende Termine bekommen Spuren nebeneinander, sonst läge
  einer unsichtbar hinter dem anderen. Eine Spur wird wieder frei, sobald
  sie zu Ende ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:37:52 +02:00

195 lines
5.6 KiB
TypeScript

import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit'
import { RASTER_MINUTEN } from '@/lib/zeitblock'
/**
* Aufbau des Wochenrasters.
*
* Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von
* Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die
* Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit.
*/
/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */
export const ZEILE_HOEHE = 14
export type Tagesspalte = {
/** 00:00 dieses Tages in Europe/Berlin. */
tagesBeginn: Date
/** 0 = Montag. */
wochentag: number
/** ISO-Datum, taugt als Schlüssel. */
schluessel: string
}
export type Raster = {
spalten: Tagesspalte[]
/** Minuten seit Mitternacht, an denen die Zeilen beginnen. */
zeilen: number[]
vonMinute: number
bisMinute: number
}
/**
* Baut das Raster für die Woche, in der der Zeitpunkt liegt.
*
* Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die
* Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was
* geplant werden darf.
*/
export function rasterBauen(
zeitpunkt: Date,
vonMinute: number,
bisMinute: number,
): Raster {
const tage = wochenTage(zeitpunkt)
const zeilen: number[] = []
for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) {
zeilen.push(minute)
}
return {
spalten: tage.map((tag) => ({
tagesBeginn: tag,
wochentag: wochentagIndex(tag),
schluessel: schluesselFuer(tag),
})),
zeilen,
vonMinute,
bisMinute,
}
}
/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */
export function schluesselFuer(tag: Date): string {
const beginn = tagesBeginn(tag)
return String(beginn.getTime())
}
/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */
export function zeitpunktAus(schluessel: string, minute: number): Date {
return new Date(Number(schluessel) + minute * 60_000)
}
/**
* Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten.
*
* Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des
* Rasters und ließe sich kaum greifen.
*/
export function fensterHuelle(
bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[],
luftMinuten = 60,
): { von: number; bis: number } {
if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 }
const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon))
const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis))
return {
von: Math.max(0, aufRasterMinute(von - luftMinuten)),
bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)),
}
}
function aufRasterMinute(minute: number): number {
return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN
}
export type Balkenlage = {
/** Abstand von oben in Pixeln. */
oben: number
/** Höhe in Pixeln. */
hoehe: number
/** Liegt der Block ganz außerhalb des sichtbaren Fensters? */
ausserhalb: boolean
}
/**
* Wo ein Zeitraum im Raster eines Tages liegt.
*
* Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst
* würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen.
*/
export function balkenlage(
beginn: Date,
ende: Date,
spalte: Tagesspalte,
raster: Raster,
): Balkenlage {
const tagBeginn = spalte.tagesBeginn.getTime()
const vonMinute = (beginn.getTime() - tagBeginn) / 60_000
const bisMinute = (ende.getTime() - tagBeginn) / 60_000
if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) {
return { oben: 0, hoehe: 0, ausserhalb: true }
}
const sichtbarVon = Math.max(vonMinute, raster.vonMinute)
const sichtbarBis = Math.min(bisMinute, raster.bisMinute)
return {
oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE,
hoehe: Math.max(
ZEILE_HOEHE,
((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE,
),
ausserhalb: false,
}
}
/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */
export function zeilenBeschriftung(minute: number): string | null {
return minute % 60 === 0 ? alsUhrzeit(minute) : null
}
/**
* Verteilt sich überschneidende Einträge auf Spuren nebeneinander.
*
* Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar.
*/
export function spurenVerteilen<T extends { beginn: Date; ende: Date }>(
eintraege: readonly T[],
): { eintrag: T; spur: number; spuren: number }[] {
const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime())
// Gruppen bilden: alles, was sich über eine Kette von Überschneidungen
// berührt, teilt sich dieselbe Anzahl Spuren.
const gruppen: T[][] = []
let aktuelle: T[] = []
let gruppenEnde = 0
for (const eintrag of sortiert) {
if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) {
gruppen.push(aktuelle)
aktuelle = []
}
aktuelle.push(eintrag)
gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime())
}
if (aktuelle.length > 0) gruppen.push(aktuelle)
const ergebnis: { eintrag: T; spur: number; spuren: number }[] = []
for (const gruppe of gruppen) {
const spurEnden: number[] = []
const zuordnung: { eintrag: T; spur: number }[] = []
for (const eintrag of gruppe) {
let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime())
if (spur === -1) {
spur = spurEnden.length
spurEnden.push(0)
}
spurEnden[spur] = eintrag.ende.getTime()
zuordnung.push({ eintrag, spur })
}
for (const { eintrag, spur } of zuordnung) {
ergebnis.push({ eintrag, spur, spuren: spurEnden.length })
}
}
return ergebnis
}