From fada4450bd773375c8796166c1ad8cccee4d7d9a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 22:37:53 +0200 Subject: [PATCH] feat(woche): Wochenansicht mit Ziehen und Ablegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links die einzuplanenden Aufgaben, rechts sieben Tage im Viertelstundenraster. Eine Aufgabe ins Raster ziehen legt einen Zeitblock an und schreibt ihn in den Kalender; ein Block lässt sich am Griff verschieben, das Kreuz plant ihn wieder aus. Die Dauer eines neuen Blocks kommt aus dem geschätzten Aufwand der Aufgabe — wer schätzt, bekommt die passende Größe geschenkt; ohne Schätzung sind es 60 Minuten. Fremde Termine liegen grau im Hintergrund und lassen sich hier nicht anfassen. Sie gehören dem Kalender, nicht dem Planer. Zwei Details: - Ziehen beginnt erst nach acht Pixeln Bewegung. Sonst löst jeder Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar. - Ein Zeitblock steht auch im Terminspiegel; er würde sonst doppelt erscheinen, einmal als eigene Planung und einmal als fremder Termin. Die Abfrage filtert ihn heraus. Der Wochenversatz steht in der Adresse, damit eine bestimmte Woche als Lesezeichen taugt. Co-Authored-By: Claude Opus 5 --- src/app/(app)/woche/page.tsx | 205 +++++++++++ src/app/(app)/woche/wochenraster.tsx | 530 +++++++++++++++++++++++++++ src/components/kopfleiste.tsx | 1 + src/server/abfragen/woche.ts | 155 ++++++++ 4 files changed, 891 insertions(+) create mode 100644 src/app/(app)/woche/page.tsx create mode 100644 src/app/(app)/woche/wochenraster.tsx create mode 100644 src/server/abfragen/woche.ts 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 } }, + }, + }) +}