diff --git a/src/app/(app)/woche/page.tsx b/src/app/(app)/woche/page.tsx new file mode 100644 index 0000000..d26f987 --- /dev/null +++ b/src/app/(app)/woche/page.tsx @@ -0,0 +1,205 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { CalendarRange } from 'lucide-react' +import { Wochenraster } from '@/app/(app)/woche/wochenraster' +import { Card, CardInhalt } from '@/components/ui/card' +import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe' +import { + belegteMinuten, + arbeitsfensterAmTag, + kapazitaetAmTag, +} from '@/lib/kalender/belegung' +import { fensterHuelle, rasterBauen } from '@/lib/woche' +import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' +import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche' +import { jetzt } from '@/server/zeitgeber' + +export const metadata: Metadata = { title: 'Woche' } + +const WOCHENTAGE = [ + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + 'Sonntag', +] + +export default async function WocheSeite({ + searchParams, +}: { + searchParams: Promise<{ versatz?: string }> +}) { + const benutzer = await verlangeAnmeldung('/woche') + const parameter = await searchParams + + // Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine + // bestimmte Woche als Lesezeichen taugt. + const versatz = Number.parseInt(parameter.versatz ?? '0', 10) + const sichereWochen = Number.isFinite(versatz) + ? Math.max(-52, Math.min(52, versatz)) + : 0 + const heute = jetzt() + const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000) + + const [daten, aufgaben, bereiche, konto] = await Promise.all([ + wocheLaden(benutzer.id, zeitpunkt), + planbareAufgabenLaden(), + db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + }), + // Für den ersten Wurf: der Zeitblock landet im ersten verbundenen + // Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt. + db.kalenderkonto.findFirst({ + where: { benutzerId: benutzer.id, aktiv: true }, + orderBy: { erstelltAm: 'asc' }, + select: { id: true, anzeigename: true }, + }), + ]) + + const huelle = fensterHuelle(bereiche) + const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis) + + // Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein + // Termin blockiert die Zeit für alles gleichermaßen. + const spalten = raster.spalten.map((spalte) => { + const tag = spalte.tagesBeginn + const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0) + + const belegend = [ + ...daten.termine.map((t) => ({ + beginn: t.beginn, + ende: t.ende, + ganztaegig: t.ganztaegig, + zeigtAls: t.zeigtAls, + })), + ...daten.zeitbloecke.map((z) => ({ + beginn: z.beginn, + ende: z.ende, + ganztaegig: false, + zeigtAls: 'GEBUCHT' as const, + })), + ] + + // Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster: + // die Tageskapazität ist hier die Summe über alle Bereiche. + const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis) + const belegt = belegteMinuten(belegend, fenster) + + return { + schluessel: spalte.schluessel, + tagesBeginnMs: spalte.tagesBeginn.getTime(), + name: WOCHENTAGE[spalte.wochentag] ?? '', + datum: alsDatum(tag).split(', ')[1] ?? '', + heute: gleicherTag(tag, heute), + kapazitaet, + belegt, + ueberbucht: kapazitaet > 0 && belegt > kapazitaet, + } + }) + + const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt) + const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt + + return ( +
+
+
+

+ + Woche +

+

+ {alsDatum(montag)} bis {alsDatum(sonntag)} + {konto ? ` · schreibt nach ${konto.anzeigename}` : ''} +

+
+ + +
+ + {!konto && ( + + + Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '} + + jetzt verbinden + + . + + + )} + + ({ + id: z.id, + beginnMs: z.beginn.getTime(), + endeMs: z.ende.getTime(), + titel: z.titel, + bereichFarbe: z.bereichFarbe, + kalenderName: z.kalenderName, + letzterFehler: z.letzterFehler, + offen: z.offen, + }))} + termine={daten.termine.map((t) => ({ + id: t.id, + beginnMs: t.beginn.getTime(), + endeMs: t.ende.getTime(), + betreff: t.betreff, + ganztaegig: t.ganztaegig, + frei: t.zeigtAls === 'FREI', + kalenderName: t.kalenderName, + }))} + aufgaben={aufgaben.map((a) => ({ + id: a.id, + titel: a.titel, + aufwandMinuten: a.aufwandMinuten, + bereichName: a.bereich?.name ?? null, + bereichFarbe: a.bereich?.farbe ?? null, + faelligText: a.faelligAm + ? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute)) + : null, + ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute), + bereitsGeplant: a._count.zeitbloecke, + }))} + /> + +

+ Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein + Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue + Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben. +

+
+ ) +} diff --git a/src/app/(app)/woche/wochenraster.tsx b/src/app/(app)/woche/wochenraster.tsx new file mode 100644 index 0000000..d1de1ed --- /dev/null +++ b/src/app/(app)/woche/wochenraster.tsx @@ -0,0 +1,530 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core' +import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import { + ZEILE_HOEHE, + balkenlage, + spurenVerteilen, + zeilenBeschriftung, + zeitpunktAus, + type Raster, +} from '@/lib/woche' +import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit' +import { cn } from '@/lib/utils' +import { + zeitblockAnlegen, + zeitblockLoesen, + zeitblockVerschieben, + type Rueckmeldung, +} from '@/server/aktionen/zeitbloecke' + +export type PlanbareAufgabe = { + id: string + titel: string + aufwandMinuten: number | null + bereichName: string | null + bereichFarbe: string | null + faelligText: string | null + ueberfaellig: boolean + bereitsGeplant: number +} + +export type RasterZeitblock = { + id: string + beginnMs: number + endeMs: number + titel: string + bereichFarbe: string | null + kalenderName: string + letzterFehler: string | null + offen: boolean +} + +export type RasterTermin = { + id: string + beginnMs: number + endeMs: number + betreff: string | null + ganztaegig: boolean + frei: boolean + kalenderName: string +} + +export type RasterSpalte = { + schluessel: string + tagesBeginnMs: number + name: string + datum: string + heute: boolean + kapazitaet: number + belegt: number + ueberbucht: boolean +} + +type Eigenschaften = { + spalten: RasterSpalte[] + zeilen: number[] + vonMinute: number + bisMinute: number + zeitbloecke: RasterZeitblock[] + termine: RasterTermin[] + aufgaben: PlanbareAufgabe[] + kalenderkontoId: string | null +} + +/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */ +const STANDARD_MINUTEN = 60 + +export function Wochenraster({ + spalten, + zeilen, + vonMinute, + bisMinute, + zeitbloecke, + termine, + aufgaben, + kalenderkontoId, +}: Eigenschaften) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [gezogen, setGezogen] = useState(null) + + const sensoren = useSensors( + // Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen + // Zug aus und Knöpfe im Block wären nicht mehr bedienbar. + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ) + + const raster: Raster = { + spalten: spalten.map((s) => ({ + tagesBeginn: new Date(s.tagesBeginnMs), + wochentag: 0, + schluessel: s.schluessel, + })), + zeilen, + vonMinute, + bisMinute, + } + + function mitRueckmeldung(tun: () => Promise) { + starte(async () => { + const antwort = await tun() + if (antwort.ok) toast.success(antwort.meldung) + else toast.error(antwort.fehler) + router.refresh() + }) + } + + function beimStart(ereignis: DragStartEvent) { + setGezogen(String(ereignis.active.id)) + } + + function beimEnde(ereignis: DragEndEvent) { + setGezogen(null) + const ziel = ereignis.over?.id + if (!ziel) return + + const [schluessel, minuteRoh] = String(ziel).split('@') + if (!schluessel || minuteRoh === undefined) return + const beginn = zeitpunktAus(schluessel, Number(minuteRoh)) + + const kennung = String(ereignis.active.id) + + if (kennung.startsWith('aufgabe:')) { + if (!kalenderkontoId) { + toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.') + return + } + const aufgabeId = kennung.slice('aufgabe:'.length) + const aufgabe = aufgaben.find((a) => a.id === aufgabeId) + const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN + const ende = new Date(beginn.getTime() + dauer * 60_000) + + const daten = new FormData() + daten.set('aufgabeId', aufgabeId) + daten.set('kalenderkontoId', kalenderkontoId) + daten.set('beginn', beginn.toISOString()) + daten.set('ende', ende.toISOString()) + mitRueckmeldung(() => zeitblockAnlegen(daten)) + return + } + + if (kennung.startsWith('block:')) { + const id = kennung.slice('block:'.length) + const block = zeitbloecke.find((z) => z.id === id) + if (!block) return + const dauer = block.endeMs - block.beginnMs + const ende = new Date(beginn.getTime() + dauer) + + const daten = new FormData() + daten.set('id', id) + daten.set('beginn', beginn.toISOString()) + daten.set('ende', ende.toISOString()) + mitRueckmeldung(() => zeitblockVerschieben(daten)) + } + } + + function ausplanen(id: string) { + const daten = new FormData() + daten.set('id', id) + mitRueckmeldung(() => zeitblockLoesen(daten)) + } + + const gezogeneAufgabe = gezogen?.startsWith('aufgabe:') + ? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length)) + : null + const gezogenerBlock = gezogen?.startsWith('block:') + ? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length)) + : null + + return ( + +
+ {/* Vorrat: was noch eingeplant werden kann */} + + + {/* Das Wochenraster */} +
+
+
+
+ {spalten.map((spalte) => ( +
+

{spalte.name}

+

{spalte.datum}

+

+ {spalte.kapazitaet === 0 + ? '—' + : `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`} +

+
+ ))} +
+ +
+ {/* Stundenleiste */} +
+ {zeilen.map((minute) => ( +
+ {zeilenBeschriftung(minute)} +
+ ))} +
+ + {spalten.map((spalte) => { + const blockeHier = zeitbloecke.filter( + (z) => + z.beginnMs < spalte.tagesBeginnMs + 86_400_000 && + z.endeMs > spalte.tagesBeginnMs, + ) + const termineHier = termine.filter( + (t) => + !t.ganztaegig && + t.beginnMs < spalte.tagesBeginnMs + 86_400_000 && + t.endeMs > spalte.tagesBeginnMs, + ) + + const verteilteTermine = spurenVerteilen( + termineHier.map((t) => ({ + ...t, + beginn: new Date(t.beginnMs), + ende: new Date(t.endeMs), + })), + ) + + return ( +
+ {zeilen.map((minute) => ( + + ))} + + {/* Fremde Termine liegen hinten und lassen sich nicht ziehen */} + {verteilteTermine.map(({ eintrag, spur, spuren }) => { + const lage = balkenlage( + eintrag.beginn, + eintrag.ende, + raster.spalten.find((s) => s.schluessel === spalte.schluessel)!, + raster, + ) + if (lage.ausserhalb) return null + return ( +
+ + {eintrag.betreff ?? 'Termin'} + +
+ ) + })} + + {/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */} + {blockeHier.map((block) => { + const lage = balkenlage( + new Date(block.beginnMs), + new Date(block.endeMs), + raster.spalten.find((s) => s.schluessel === spalte.schluessel)!, + raster, + ) + if (lage.ausserhalb) return null + return ( + ausplanen(block.id)} + /> + ) + })} +
+ ) + })} +
+
+
+
+ + + {gezogeneAufgabe && ( +
+ {gezogeneAufgabe.titel} + + {alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)} + +
+ )} + {gezogenerBlock && ( +
+ {gezogenerBlock.titel} +
+ )} +
+ + ) +} + +function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) { + const { setNodeRef, isOver } = useDroppable({ id: kennung }) + return ( +
+ ) +} + +function AufgabenKachel({ + aufgabe, + gesperrt, +}: { + aufgabe: PlanbareAufgabe + gesperrt: boolean +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `aufgabe:${aufgabe.id}`, + disabled: gesperrt, + }) + + return ( +
  • + + +
    +

    {aufgabe.titel}

    +
    + {aufgabe.bereichName && ( + + + {aufgabe.bereichName} + + )} + + {alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)} + + {aufgabe.faelligText && ( + + {aufgabe.faelligText} + + )} + {aufgabe.bereitsGeplant > 0 && ( + + + {aufgabe.bereitsGeplant} + + )} +
    +
    +
  • + ) +} + +function Zeitblockbalken({ + block, + oben, + hoehe, + gesperrt, + onAusplanen, +}: { + block: RasterZeitblock + oben: number + hoehe: number + gesperrt: boolean + onAusplanen: () => void +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `block:${block.id}`, + disabled: gesperrt, + }) + + const farbe = block.bereichFarbe ?? 'var(--betont)' + + return ( +
    +
    + + + {block.titel} + + {(block.offen || block.letzterFehler) && ( + + )} + + +
    +
    + ) +} diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx index f547cc3..e00c58d 100644 --- a/src/components/kopfleiste.tsx +++ b/src/components/kopfleiste.tsx @@ -30,6 +30,7 @@ export function Kopfleiste({ { pfad: '/', name: 'Start' }, { pfad: '/inbox', name: 'Inbox', zaehler: inbox }, { pfad: '/aufgaben', name: 'Aufgaben' }, + { pfad: '/woche', name: 'Woche' }, { pfad: '/projekte', name: 'Projekte' }, { pfad: '/ideen', name: 'Ideen' }, { pfad: '/bereiche', name: 'Bereiche' }, diff --git a/src/server/abfragen/woche.ts b/src/server/abfragen/woche.ts new file mode 100644 index 0000000..bbb7f0d --- /dev/null +++ b/src/server/abfragen/woche.ts @@ -0,0 +1,155 @@ +import 'server-only' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { tagesBeginn, wochenBeginn } from '@/lib/zeit' +import { db } from '@/server/db' + +/** + * Alles, was die Wochenansicht braucht — in einem Zug geladen. + * + * Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen + * getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich + * verschieben, ein Termin ist fremd und wird nur angezeigt. + */ + +export type WochenZeitblock = { + id: string + beginn: Date + ende: Date + aufgabeId: string + titel: string + bereichName: string | null + bereichFarbe: string | null + kalenderName: string + letzterFehler: string | null + /** Noch nicht in den Kalender geschrieben. */ + offen: boolean +} + +export type WochenTermin = { + id: string + beginn: Date + ende: Date + betreff: string | null + ganztaegig: boolean + zeigtAls: ZeigtAls + kalenderName: string +} + +export type WocheDaten = { + zeitbloecke: WochenZeitblock[] + termine: WochenTermin[] +} + +/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */ +export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } { + const montag = wochenBeginn(zeitpunkt) + return { + von: montag, + bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()), + } +} + +export async function wocheLaden( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const fenster = wochenFenster(zeitpunkt) + + const [zeitbloecke, termine] = await Promise.all([ + db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId }, + beginn: { lt: fenster.bis }, + ende: { gt: fenster.von }, + }, + orderBy: { beginn: 'asc' }, + select: { + id: true, + beginn: true, + ende: true, + extId: true, + letzterFehler: true, + aufgabe: { + select: { + id: true, + titel: true, + bereich: { select: { name: true, farbe: true } }, + }, + }, + kalenderkonto: { select: { anzeigename: true } }, + }, + }), + db.termin.findMany({ + where: { + kalenderkonto: { benutzerId, aktiv: true }, + beginn: { lt: fenster.bis }, + ende: { gt: fenster.von }, + }, + orderBy: { beginn: 'asc' }, + select: { + id: true, + extId: true, + beginn: true, + ende: true, + betreff: true, + ganztaegig: true, + zeigtAls: true, + kalenderkonto: { select: { anzeigename: true } }, + }, + }), + ]) + + // Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst + // doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin. + const eigeneExtIds = new Set( + zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null), + ) + + return { + zeitbloecke: zeitbloecke.map((z) => ({ + id: z.id, + beginn: z.beginn, + ende: z.ende, + aufgabeId: z.aufgabe.id, + titel: z.aufgabe.titel, + bereichName: z.aufgabe.bereich?.name ?? null, + bereichFarbe: z.aufgabe.bereich?.farbe ?? null, + kalenderName: z.kalenderkonto.anzeigename, + letzterFehler: z.letzterFehler, + offen: z.extId === null, + })), + termine: termine + .filter((t) => !eigeneExtIds.has(t.extId)) + .map((t) => ({ + id: t.id, + beginn: t.beginn, + ende: t.ende, + betreff: t.betreff, + ganztaegig: t.ganztaegig, + zeigtAls: t.zeigtAls, + kalenderName: t.kalenderkonto.anzeigename, + })), + } +} + +/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */ +export async function planbareAufgabenLaden(grenze = 50) { + return db.aufgabe.findMany({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + }, + orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }], + take: grenze, + select: { + id: true, + titel: true, + aufwandMinuten: true, + faelligAm: true, + prioritaet: true, + status: true, + bereich: { select: { id: true, name: true, farbe: true } }, + _count: { select: { zeitbloecke: true } }, + }, + }) +}