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( 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 }