feat(woche): Wochenansicht mit Ziehen und Ablegen

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 22:37:53 +02:00
co-authored by Claude Opus 5
parent bcb4b8eb02
commit fada4450bd
4 changed files with 891 additions and 0 deletions
+205
View File
@@ -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 (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarRange className="size-6" aria-hidden />
Woche
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{alsDatum(montag)} bis {alsDatum(sonntag)}
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
<Link
href={`/woche?versatz=${sichereWochen - 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Zurück
</Link>
<Link
href="/woche"
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Diese Woche
</Link>
<Link
href={`/woche?versatz=${sichereWochen + 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Weiter
</Link>
</nav>
</header>
{!konto && (
<Card>
<CardInhalt className="py-4 text-sm">
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
<Link
href="/kalenderkonten"
className="text-betont underline underline-offset-4"
>
jetzt verbinden
</Link>
.
</CardInhalt>
</Card>
)}
<Wochenraster
spalten={spalten}
zeilen={raster.zeilen}
vonMinute={raster.vonMinute}
bisMinute={raster.bisMinute}
kalenderkontoId={konto?.id ?? null}
zeitbloecke={daten.zeitbloecke.map((z) => ({
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,
}))}
/>
<p className="text-gedaempft-vordergrund text-xs">
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.
</p>
</div>
)
}
+530
View File
@@ -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<string | null>(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<Rueckmeldung>) {
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 (
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Vorrat: was noch eingeplant werden kann */}
<aside className="lg:w-72 lg:shrink-0">
<h2 className="mb-2 text-sm font-medium">
Einzuplanen
<span className="text-gedaempft-vordergrund ml-2 font-normal">
{aufgaben.length}
</span>
</h2>
{!kalenderkontoId && (
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
Ohne verbundenen Kalender lässt sich nichts einplanen.
</p>
)}
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
{aufgaben.length === 0 ? (
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
Nichts offen. Ein guter Tag.
</li>
) : (
aufgaben.map((aufgabe) => (
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
))
)}
</ul>
</aside>
{/* Das Wochenraster */}
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
<div />
{spalten.map((spalte) => (
<div
key={spalte.schluessel}
className={cn(
'border-rand border-b px-1 pb-2 text-center',
spalte.heute && 'bg-betont/5',
)}
>
<p className="text-sm font-medium">{spalte.name}</p>
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
<p
className={cn(
'mt-1 text-xs',
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
)}
>
{spalte.kapazitaet === 0
? '—'
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
{/* Stundenleiste */}
<div className="relative">
{zeilen.map((minute) => (
<div
key={minute}
style={{ height: ZEILE_HOEHE }}
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
>
{zeilenBeschriftung(minute)}
</div>
))}
</div>
{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 (
<div
key={spalte.schluessel}
className={cn(
'border-rand relative border-l',
spalte.heute && 'bg-betont/5',
)}
>
{zeilen.map((minute) => (
<Zielfeld
key={minute}
kennung={`${spalte.schluessel}@${minute}`}
volleStunde={minute % 60 === 0}
/>
))}
{/* 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 (
<div
key={eintrag.id}
className={cn(
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
)}
style={{
top: lage.oben,
height: lage.hoehe,
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
>
<span className="line-clamp-2">
{eintrag.betreff ?? 'Termin'}
</span>
</div>
)
})}
{/* 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 (
<Zeitblockbalken
key={block.id}
block={block}
oben={lage.oben}
hoehe={lage.hoehe}
gesperrt={laeuft}
onAusplanen={() => ausplanen(block.id)}
/>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
<DragOverlay>
{gezogeneAufgabe && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogeneAufgabe.titel}
<span className="ml-1 opacity-80">
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</span>
</div>
)}
{gezogenerBlock && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogenerBlock.titel}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: kennung })
return (
<div
ref={setNodeRef}
style={{ height: ZEILE_HOEHE }}
className={cn(
'border-rand/40',
volleStunde ? 'border-t' : 'border-t border-dashed',
isOver && 'bg-betont/20',
)}
/>
)
}
function AufgabenKachel({
aufgabe,
gesperrt,
}: {
aufgabe: PlanbareAufgabe
gesperrt: boolean
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `aufgabe:${aufgabe.id}`,
disabled: gesperrt,
})
return (
<li
ref={setNodeRef}
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
>
<button
type="button"
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
{...listeners}
{...attributes}
>
<GripVertical className="size-4" aria-hidden />
</button>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{aufgabe.titel}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
{aufgabe.bereichName && (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
/>
{aufgabe.bereichName}
</Kennzeichen>
)}
<Kennzeichen>
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</Kennzeichen>
{aufgabe.faelligText && (
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.bereitsGeplant > 0 && (
<Kennzeichen art="betont">
<CalendarClock className="size-3" aria-hidden />
{aufgabe.bereitsGeplant}
</Kennzeichen>
)}
</div>
</div>
</li>
)
}
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 (
<div
ref={setNodeRef}
style={{
top: oben,
height: hoehe,
borderLeftColor: farbe,
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
}}
className={cn(
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
isDragging && 'opacity-40',
)}
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
>
<div className="flex items-start gap-0.5">
<button
type="button"
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${block.titel} verschieben`}
{...listeners}
{...attributes}
>
<GripVertical className="size-3 opacity-60" aria-hidden />
</button>
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
{(block.offen || block.letzterFehler) && (
<AlertTriangle
className="text-warnung size-3 shrink-0"
aria-label="Noch nicht im Kalender"
/>
)}
<Button
art="leise"
className="size-4 shrink-0 p-0"
disabled={gesperrt}
title="Ausplanen"
onClick={onAusplanen}
>
<X className="size-3" aria-hidden />
<span className="sr-only">Ausplanen</span>
</Button>
</div>
</div>
)
}
+1
View File
@@ -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' },
+155
View File
@@ -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<WocheDaten> {
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 } },
},
})
}