feat(kalender): Belegung des Tages auf der Startseite

Jeder Bereich zeigt, wie voll der heutige Tag schon ist: Termine aus allen
verbundenen Kalendern gegen die Tageskapazität dieses Bereichs.

Alle Kalender zählen gegen jeden Bereich, weil ein Termin die Zeit ohnehin
blockiert, gleich zu welchem Lebensbereich er gehört. Wer um 14 Uhr beim
Zahnarzt sitzt, kann zur selben Zeit nichts für die Firma tun.

Ohne verbundenen Kalender bleibt der Balken weg. Er wäre sonst immer bei
null und sähe nach "alles frei" aus — eine Zahl, die lügt, ist schlechter
als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:12:04 +02:00
co-authored by Claude Opus 5
parent 42c98c8b29
commit d8d3227c82
2 changed files with 113 additions and 12 deletions
+29 -3
View File
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import {
Card,
CardBeschreibung,
@@ -16,6 +17,7 @@ import {
projekteZurAuswahlLaden,
zaehlstaende,
} from '@/server/abfragen/aufgaben'
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
@@ -24,7 +26,8 @@ export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const heute = jetzt()
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
@@ -33,6 +36,8 @@ export default async function StartSeite() {
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
belegungHeute(benutzer.id, heute),
hatKalenderkonto(benutzer.id),
])
// Was jetzt zählt: überfällig, heute oder morgen fällig.
@@ -108,9 +113,10 @@ export default async function StartSeite() {
<ul className="grid gap-4 sm:grid-cols-2">
{bereichsListe.map((bereich) => {
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
return (
<li key={bereich.id}>
<Card>
<Card className="h-full">
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
@@ -119,13 +125,33 @@ export default async function StartSeite() {
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt>
<CardInhalt className="flex flex-col gap-2">
<p className="text-gedaempft-vordergrund text-sm">
{offeneHier.length === 0
? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p>
{/*
Ohne verbundenen Kalender wäre die Belegung immer null –
das sähe nach "alles frei" aus und wäre irreführend.
*/}
{mitKalender && heuteHier && (
<>
<Fortschrittsbalken
prozent={heuteHier.prozent}
beschriftung={`Belegung heute in ${bereich.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{heuteHier.kapazitaet === 0
? 'Heute nicht eingeplant'
: heuteHier.ueberbucht
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
</p>
</>
)}
</CardInhalt>
</Card>
</li>
+75
View File
@@ -0,0 +1,75 @@
import 'server-only'
import {
tagesBelegung,
termineAmTag,
type BelegenderTermin,
type TagesBelegung,
} from '@/lib/kalender/belegung'
import { tagesBeginn, tagesEnde } from '@/lib/zeit'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Abfragen rund um die gespiegelten Termine.
*
* Die Termine gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
* deshalb immer in die where-Bedingung.
*/
export type BereichMitBelegung = {
id: string
name: string
farbe: string
belegung: TagesBelegung
}
/** Alle Termine des Benutzers, die diesen Kalendertag berühren. */
export async function termineAmTagLaden(
benutzerId: string,
tag: Date,
): Promise<BelegenderTermin[]> {
const zeilen = await db.termin.findMany({
where: {
kalenderkonto: { benutzerId, aktiv: true },
beginn: { lte: tagesEnde(tag) },
ende: { gt: tagesBeginn(tag) },
},
select: { beginn: true, ende: true, ganztaegig: true, zeigtAls: true },
orderBy: { beginn: 'asc' },
})
return zeilen
}
/**
* Wie voll heute in jedem Bereich schon ist.
*
* Alle Bereiche rechnen gegen dieselben Termine — der Planer weiß nicht,
* welcher Termin zu welchem Bereich gehört, und ein Termin blockiert die Zeit
* ohnehin für alles andere mit.
*/
export async function belegungHeute(
benutzerId: string,
zeitpunkt: Date = jetzt(),
): Promise<BereichMitBelegung[]> {
const [bereiche, termine] = await Promise.all([
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
termineAmTagLaden(benutzerId, zeitpunkt),
])
const heute = termineAmTag(termine, zeitpunkt)
return bereiche.map((bereich) => ({
id: bereich.id,
name: bereich.name,
farbe: bereich.farbe,
belegung: tagesBelegung(bereich, heute, zeitpunkt),
}))
}
/** Hat der Benutzer überhaupt einen Kalender verbunden? */
export async function hatKalenderkonto(benutzerId: string): Promise<boolean> {
return (await db.kalenderkonto.count({ where: { benutzerId, aktiv: true } })) > 0
}