Implement ST-403 ad hoc activities

This commit is contained in:
Janpeter Visser 2026-04-19 10:04:55 +02:00
parent 57ade6a772
commit a8366932a0
13 changed files with 491 additions and 51 deletions

View file

@ -197,7 +197,7 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
{previewPoints === null
? "Kies een geldige duur en impact om te zien hoeveel punten deze activiteit ongeveer toevoegt."
: `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je totaal zou dan uitkomen op ${previewMeter?.plannedPoints ?? currentMeter.plannedPoints} geplande punten.`}
: `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je totaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten in beeld.`}
</p>
{dailyBudget !== null && previewMeter ? (
<p className="text-sm leading-7 text-foreground/80" aria-live="polite">
@ -324,7 +324,7 @@ export function ActivityForm({ categories, activities, dailyBudget }: ActivityFo
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
{isPending
? "Je activiteit wordt opgeslagen..."
: "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna de meter direct opnieuw wordt berekend."}
: "Je activiteit wordt vandaag toegevoegd met status `gepland`, waarna je dagtotaal direct opnieuw wordt berekend."}
</p>
<Button

View file

@ -0,0 +1,282 @@
"use client";
import { useActionState, useMemo, useState } from "react";
import { createAdHocActivityAction } from "@/app/planning/actions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ACTIVITY_DURATION_SUGGESTIONS,
ACTIVITY_IMPACT_OPTIONS,
} from "@/lib/planning/form-options";
import {
calculatePlanningMeterSnapshot,
deriveActivityEnergyPoints,
} from "@/lib/planning/meter";
import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types";
import { cn } from "@/lib/utils";
type AdHocActivityFormProps = {
categories: ActivityCategory[];
activities: ActivityRecord[];
dailyBudget: number | null;
};
export function AdHocActivityForm({
categories,
activities,
dailyBudget,
}: AdHocActivityFormProps) {
const [, formAction, isPending] = useActionState(createAdHocActivityAction, null);
const [name, setName] = useState("");
const [categoryId, setCategoryId] = useState<string>(categories[0]?.id ?? "");
const [durationMinutes, setDurationMinutes] = useState("30");
const [impactLevel, setImpactLevel] = useState<"laag" | "midden" | "hoog">("midden");
const selectedCategory = useMemo(
() => categories.find((category) => category.id === categoryId) ?? null,
[categories, categoryId],
);
const currentMeter = useMemo(
() => calculatePlanningMeterSnapshot(activities, dailyBudget),
[activities, dailyBudget],
);
const previewPoints = useMemo(() => {
const parsedDuration = Number.parseInt(durationMinutes, 10);
if (!Number.isFinite(parsedDuration) || parsedDuration <= 0) {
return null;
}
return deriveActivityEnergyPoints({
durationMinutes: parsedDuration,
impactLevel,
status: "completed",
});
}, [durationMinutes, impactLevel]);
const previewMeter = useMemo(() => {
if (previewPoints === null) {
return null;
}
return calculatePlanningMeterSnapshot(
[
...activities,
{
durationMinutes: Number.parseInt(durationMinutes, 10),
impactLevel,
status: "completed",
} as ActivityRecord,
],
dailyBudget,
);
}, [activities, dailyBudget, durationMinutes, impactLevel, previewPoints]);
return (
<form action={formAction} className="space-y-6" aria-busy={isPending}>
<input type="hidden" name="categoryId" value={categoryId} />
<input type="hidden" name="impactLevel" value={impactLevel} />
<Card tone="subtle" className="py-0">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Ongepland
</p>
<CardTitle className="text-2xl text-foreground">
Voeg iets toe dat vandaag spontaan gebeurde
</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
Gebruik dit voor activiteiten die niet vooraf gepland waren, maar wel
onderdeel zijn geworden van je echte dag. Ze worden opgeslagen als
<strong> ongepland en uitgevoerd</strong>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pb-6">
<div className="space-y-2">
<Label htmlFor="ad-hoc-activity-name" className="text-foreground">
Naam van de ongeplande activiteit
</Label>
<Input
id="ad-hoc-activity-name"
name="name"
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
disabled={isPending}
maxLength={120}
placeholder="Bijvoorbeeld: onverwacht telefoontje of extra boodschap"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-foreground">Categorie</Label>
<Select
disabled={isPending}
value={categoryId}
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
<SelectValue placeholder="Kies een categorie" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.labelNl}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCategory ? (
<p className="text-sm leading-7 text-muted-foreground">
Gekozen categorie: <strong>{selectedCategory.labelNl}</strong>.
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="ad-hoc-duration-minutes" className="text-foreground">
Geschatte duur in minuten
</Label>
<Input
id="ad-hoc-duration-minutes"
name="durationMinutes"
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
disabled={isPending}
inputMode="numeric"
min={1}
max={720}
step={1}
type="number"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
<div className="flex flex-wrap gap-2" role="group" aria-label="Snelle duurkeuzes">
{ACTIVITY_DURATION_SUGGESTIONS.map((value) => (
<button
key={value}
type="button"
disabled={isPending}
onClick={() => setDurationMinutes(String(value))}
aria-pressed={durationMinutes === String(value)}
className={cn(
buttonVariants({
variant: durationMinutes === String(value) ? "default" : "outline",
size: "sm",
}),
"rounded-full px-3",
)}
>
{value} min
</button>
))}
</div>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label id="ad-hoc-impact-group-label" className="text-sm font-semibold text-foreground">
Ervaren impact
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Kies hoe belastend deze onverwachte activiteit achteraf aanvoelde.
</p>
</div>
<div className="grid gap-3" role="group" aria-labelledby="ad-hoc-impact-group-label">
{ACTIVITY_IMPACT_OPTIONS.map((option) => {
const isSelected = impactLevel === option.value;
return (
<button
key={option.value}
type="button"
disabled={isPending}
onClick={() => setImpactLevel(option.value)}
aria-pressed={isSelected}
className={cn(
"rounded-[1.25rem] border px-4 py-4 text-left transition",
isSelected
? "border-primary bg-primary text-primary-foreground shadow-[var(--shadow-2)]"
: "border-border/60 bg-background/80 text-foreground hover:border-primary/35",
isPending && "pointer-events-none opacity-70",
)}
>
<span className="block text-sm font-semibold">{option.label}</span>
<span
className={cn(
"mt-2 block text-sm leading-6",
isSelected ? "text-primary-foreground/85" : "text-muted-foreground",
)}
>
{option.description}
</span>
</button>
);
})}
</div>
</div>
<Card tone="subtle" className="py-0 shadow-none">
<CardContent className="space-y-2 py-5">
<p className="text-sm font-semibold text-foreground">Effect op je dagtotaal</p>
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
{previewPoints === null
? "Kies een geldige duur en impact om te zien hoeveel punten deze ongeplande activiteit ongeveer toevoegt."
: `Deze activiteit telt voorlopig voor ${previewPoints} punten. Je dagtotaal zou dan uitkomen op ${previewMeter?.totalPoints ?? currentMeter.totalPoints} punten.`}
</p>
{dailyBudget !== null && previewMeter ? (
<p className="text-sm leading-7 text-foreground/80" aria-live="polite">
Dat is {previewMeter.dailyBudget} punten budget, met daarna nog{" "}
<strong>{previewMeter.remainingBudget} punten ruimte</strong>.
</p>
) : null}
{previewMeter?.isOverBudget ? (
<Alert variant="warning">
<AlertTitle className="text-sm">Niet-blokkerende waarschuwing</AlertTitle>
<AlertDescription className="leading-7 text-current">
Met deze ongeplande activiteit kom je ongeveer{" "}
<strong>{Math.abs(previewMeter.remainingBudget ?? 0)} punten</strong> boven je dagbudget uit.
Je kunt nog steeds opslaan, maar dit helpt je later beter begrijpen waarom je dag zwaarder uitviel.
</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm leading-7 text-muted-foreground" aria-live="polite">
{isPending
? "Je ongeplande activiteit wordt opgeslagen..."
: "Deze activiteit wordt vandaag toegevoegd met bron `ongepland` en status `uitgevoerd`."}
</p>
<Button
type="submit"
size="lg"
disabled={isPending || !name.trim() || !categoryId || !durationMinutes.trim()}
className="h-11 rounded-full px-5"
>
{isPending ? "Ongeplande activiteit opslaan..." : "Voeg ongeplande activiteit toe"}
</Button>
</div>
</form>
);
}

View file

@ -36,10 +36,10 @@ function getMeterDescription(meter: PlanningMeterSnapshot) {
}
if (meter.isOverBudget) {
return "Je planning zit boven je dagbudget. Dat is een signaal om eventueel iets te verschuiven of lichter te maken, niet om te blokkeren.";
return "Je dagtotaal zit boven je dagbudget. Dat is een signaal om eventueel iets te verschuiven of lichter te maken, niet om te blokkeren.";
}
return "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van je activiteiten.";
return "De meter blijft bewust eenvoudig: punten volgen uit duur en impact van alle activiteiten die vandaag in beeld zijn.";
}
export function EnergyMeterCard({
@ -57,8 +57,8 @@ export function EnergyMeterCard({
</p>
<CardTitle className="text-lg text-foreground">
{meter.dailyBudget === null
? `${meter.plannedPoints} geplande punten`
: `${meter.plannedPoints} van ${meter.dailyBudget} punten gepland`}
? `${meter.totalPoints} punten in beeld`
: `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld`}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pb-6">
@ -77,7 +77,7 @@ export function EnergyMeterCard({
aria-valuetext={
meter.dailyBudget === null
? "Nog geen dagbudget beschikbaar"
: `${meter.plannedPoints} van ${meter.dailyBudget} punten gepland`
: `${meter.totalPoints} van ${meter.dailyBudget} punten in beeld`
}
>
<div
@ -105,10 +105,10 @@ export function EnergyMeterCard({
</div>
{meter.dailyBudget !== null && meter.isOverBudget ? (
<Alert variant="warning">
<AlertTitle className="text-sm">Je zit boven je dagbudget</AlertTitle>
<AlertDescription className="leading-7 text-current">
Je planning komt nu <strong>{Math.abs(meter.remainingBudget ?? 0)} punten</strong> boven het dagbudget uit.
<Alert variant="warning">
<AlertTitle className="text-sm">Je zit boven je dagbudget</AlertTitle>
<AlertDescription className="leading-7 text-current">
Je dagtotaal komt nu <strong>{Math.abs(meter.remainingBudget ?? 0)} punten</strong> boven het dagbudget uit.
Je kunt nog steeds doorgaan, maar dit is een goed moment om iets te schrappen, te verkorten of later te doen.
</AlertDescription>
</Alert>

View file

@ -81,6 +81,22 @@ function getStatusBadgeClassName(value: ActivityRecord["status"]) {
return "bg-secondary text-secondary-foreground";
}
function formatSourceLabel(value: ActivityRecord["source"]) {
if (value === "ad_hoc") {
return "Ongepland";
}
return "Gepland";
}
function getSourceBadgeClassName(value: ActivityRecord["source"]) {
if (value === "ad_hoc") {
return "bg-primary text-primary-foreground";
}
return "bg-muted text-muted-foreground";
}
export function TodayActivitiesList({
activities,
categories,
@ -90,18 +106,18 @@ export function TodayActivitiesList({
<Card className="py-0">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Vandaag gepland
Vandaag in beeld
</p>
<CardTitle className="text-lg text-foreground">
{activities.length === 0
? "Nog geen activiteiten gepland"
? "Nog geen activiteiten toegevoegd"
: `${activities.length} ${activities.length === 1 ? "activiteit" : "activiteiten"}`}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pb-6">
{activities.length === 0 ? (
<CardDescription className="text-sm leading-7 text-muted-foreground">
Je dag is nog leeg. Plan eerst een kleine concrete activiteit om de flow op gang te brengen.
Je dag is nog leeg. Plan eerst iets kleins of voeg later een ongeplande activiteit toe als je dag anders liep dan verwacht.
</CardDescription>
) : (
activities.map((activity) => (
@ -116,14 +132,24 @@ export function TodayActivitiesList({
{getCategoryLabel(categories, activity.categoryId)}
</p>
</div>
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
getStatusBadgeClassName(activity.status),
)}
>
{formatStatusLabel(activity.status)}
</span>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
getSourceBadgeClassName(activity.source),
)}
>
{formatSourceLabel(activity.source)}
</span>
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]",
getStatusBadgeClassName(activity.status),
)}
>
{formatStatusLabel(activity.status)}
</span>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm leading-7 text-foreground/80 sm:grid-cols-3">