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
+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 } },
},
})
}