diff --git a/package-lock.json b/package-lock.json index de91e48..b1bf9fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/utilities": "3.2.2", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@radix-ui/react-avatar": "1.2.6", @@ -593,6 +595,45 @@ "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "license": "MIT" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", diff --git a/package.json b/package.json index f696d25..4fe0563 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/utilities": "3.2.2", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@radix-ui/react-avatar": "1.2.6", diff --git a/src/lib/woche.test.ts b/src/lib/woche.test.ts new file mode 100644 index 0000000..029db7a --- /dev/null +++ b/src/lib/woche.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + ZEILE_HOEHE, + balkenlage, + fensterHuelle, + rasterBauen, + schluesselFuer, + spurenVerteilen, + zeilenBeschriftung, + zeitpunktAus, +} from './woche' + +// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1. +const dienstag = new Date('2026-03-17T09:00:00Z') + +describe('rasterBauen', () => { + it('beginnt am Montag und hat sieben Spalten', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + expect(raster.spalten).toHaveLength(7) + expect(raster.spalten[0]?.wochentag).toBe(0) + expect(raster.spalten[6]?.wochentag).toBe(6) + }) + + it('legt den Montag auf den richtigen Tag', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + // Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15. + expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z') + }) + + it('teilt das Fenster in Viertelstunden', () => { + const raster = rasterBauen(dienstag, 8 * 60, 10 * 60) + expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585]) + }) + + it('lässt die letzte Zeile vor dem Ende enden', () => { + const raster = rasterBauen(dienstag, 8 * 60, 9 * 60) + expect(raster.zeilen.at(-1)).toBe(525) + }) +}) + +describe('schluesselFuer und zeitpunktAus', () => { + it('finden zurück zum Ausgangspunkt', () => { + const schluessel = schluesselFuer(dienstag) + // 09:00 Berlin am selben Tag. + expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe( + '2026-03-17T08:00:00.000Z', + ) + }) + + it('geben für denselben Tag denselben Schlüssel', () => { + expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe( + schluesselFuer(new Date('2026-03-17T20:00:00Z')), + ) + }) +}) + +describe('fensterHuelle', () => { + it('umspannt alle Arbeitsfenster mit etwas Luft', () => { + const huelle = fensterHuelle([ + { arbeitsfensterVon: 480, arbeitsfensterBis: 1020 }, + { arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 }, + ]) + expect(huelle.von).toBe(420) + expect(huelle.bis).toBe(1320) + }) + + it('bleibt innerhalb eines Tages', () => { + const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }]) + expect(huelle.von).toBe(0) + expect(huelle.bis).toBe(1440) + }) + + it('nimmt ohne Bereiche einen brauchbaren Standard', () => { + expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 }) + }) +}) + +describe('balkenlage', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + const spalte = raster.spalten[1]! + + it('setzt einen Block an die richtige Stelle', () => { + // 09:00 bis 10:00 Berlin. + const lage = balkenlage( + new Date('2026-03-17T08:00:00Z'), + new Date('2026-03-17T09:00:00Z'), + spalte, + raster, + ) + // Eine Stunde nach Fensterbeginn = vier Zeilen. + expect(lage.oben).toBe(4 * ZEILE_HOEHE) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + expect(lage.ausserhalb).toBe(false) + }) + + it('schneidet oben ab, was vor dem Fenster beginnt', () => { + // 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00. + const lage = balkenlage( + new Date('2026-03-17T06:00:00Z'), + new Date('2026-03-17T08:00:00Z'), + spalte, + raster, + ) + expect(lage.oben).toBe(0) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + }) + + it('schneidet unten ab, was über das Fenster hinausgeht', () => { + // 17:00 bis 20:00 Berlin, Fenster endet um 18:00. + const lage = balkenlage( + new Date('2026-03-17T16:00:00Z'), + new Date('2026-03-17T19:00:00Z'), + spalte, + raster, + ) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + }) + + it('erkennt einen Block ganz außerhalb des Fensters', () => { + // 22:00 bis 23:00 Berlin. + const lage = balkenlage( + new Date('2026-03-17T21:00:00Z'), + new Date('2026-03-17T22:00:00Z'), + spalte, + raster, + ) + expect(lage.ausserhalb).toBe(true) + }) + + it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => { + const lage = balkenlage( + new Date('2026-03-17T08:00:00Z'), + new Date('2026-03-17T08:15:00Z'), + spalte, + raster, + ) + expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE) + }) +}) + +describe('zeilenBeschriftung', () => { + it('beschriftet nur volle Stunden', () => { + expect(zeilenBeschriftung(480)).toBe('08:00') + expect(zeilenBeschriftung(495)).toBeNull() + expect(zeilenBeschriftung(540)).toBe('09:00') + }) +}) + +describe('spurenVerteilen', () => { + function eintrag(von: string, bis: string) { + return { beginn: new Date(von), ende: new Date(bis) } + } + + it('gibt einem einzelnen Eintrag die volle Breite', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + ]) + expect(verteilt[0]?.spur).toBe(0) + expect(verteilt[0]?.spuren).toBe(1) + }) + + it('legt zwei sich überschneidende Einträge nebeneinander', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + ]) + expect(verteilt.map((v) => v.spur)).toEqual([0, 1]) + expect(verteilt.every((v) => v.spuren === 2)).toBe(true) + }) + + it('lässt getrennte Einträge beide die volle Breite behalten', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'), + ]) + expect(verteilt.every((v) => v.spuren === 1)).toBe(true) + }) + + it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + // Beginnt nach dem ersten, kann dessen Spur übernehmen. + eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'), + ]) + expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0]) + // Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren. + expect(verteilt.every((v) => v.spuren === 2)).toBe(true) + }) + + it('trennt Gruppen, die sich nicht berühren', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'), + eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'), + ]) + expect(verteilt[0]?.spuren).toBe(2) + expect(verteilt[1]?.spuren).toBe(2) + expect(verteilt[2]?.spuren).toBe(1) + }) + + it('behält jeden Eintrag genau einmal', () => { + const eintraege = [ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'), + ] + expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length) + }) +}) diff --git a/src/lib/woche.ts b/src/lib/woche.ts new file mode 100644 index 0000000..45ad320 --- /dev/null +++ b/src/lib/woche.ts @@ -0,0 +1,194 @@ +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 +}