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

@ -168,12 +168,12 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
<CardTitle className="text-lg text-foreground">
{planningStatus?.activities.length
? `${planningStatus.activities.length} activiteiten voor vandaag`
: "Nog niets gepland voor vandaag"}
: "Nog niets toegevoegd voor vandaag"}
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<CardDescription className="text-sm leading-7 text-muted-foreground">
Plan kleine, concrete activiteiten voor vandaag en bouw daarna verder op budgetfeedback en evaluatie.
Plan kleine, concrete activiteiten voor vandaag en leg ook onverwachte activiteiten vast als je dag anders loopt dan gedacht.
</CardDescription>
<div className="mt-4">
<Link href="/planning" className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary">

View file

@ -8,11 +8,13 @@ import {
ACTIVITY_STATUS_VALUES,
} from "@/lib/planning/options";
import {
createAdHocActivityForTodayForCurrentUser,
createActivityForTodayForCurrentUser,
updateActivityEvaluationForTodayForCurrentUser,
updateActivityStatusForTodayForCurrentUser,
} from "@/lib/planning/service";
import type {
CreateAdHocActivitySubmission,
CreateActivitySubmission,
UpdateActivityEvaluationSubmission,
UpdateActivityStatusSubmission,
@ -59,6 +61,33 @@ function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmis
};
}
function buildCreateAdHocActivitySubmission(
formData: FormData,
): CreateAdHocActivitySubmission {
const name = assertMaxLength(
getRequiredString(formData, "name", "invalid-ad-hoc-activity-input"),
120,
"invalid-ad-hoc-activity-input",
);
return {
name,
categoryId: getUuidValue(formData, "categoryId", "invalid-ad-hoc-activity-input"),
durationMinutes: getIntegerValue(
formData,
"durationMinutes",
{ min: 1, max: 720 },
"invalid-ad-hoc-activity-input",
),
impactLevel: getEnumValue(
formData,
"impactLevel",
ACTIVITY_IMPACT_LEVEL_VALUES,
"invalid-ad-hoc-activity-input",
),
};
}
function buildUpdateActivityStatusSubmission(
formData: FormData,
): UpdateActivityStatusSubmission {
@ -113,6 +142,30 @@ export async function createActivityAction(
return null;
}
export async function createAdHocActivityAction(
_previousState: null,
formData: FormData,
): Promise<null> {
try {
await createAdHocActivityForTodayForCurrentUser(
buildCreateAdHocActivitySubmission(formData),
);
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/planning", { error: error.code }));
}
if (error instanceof Error && error.message === "Ongeldige activiteitcategorie.") {
redirect(buildPathWithQuery("/planning", { error: "invalid-ad-hoc-activity-input" }));
}
redirect(buildPathWithQuery("/planning", { error: "ad-hoc-activity-failed" }));
}
redirect(buildPathWithQuery("/planning", { status: "ad-hoc-activity-saved" }));
return null;
}
export async function updateActivityStatusAction(
_previousState: null,
formData: FormData,

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { AdHocActivityForm } from "@/components/planning/ad-hoc-activity-form";
import { AppShell } from "@/components/navigation/app-shell";
import { PageIntro } from "@/components/navigation/page-intro";
import { ActivityForm } from "@/components/planning/activity-form";
@ -88,11 +89,18 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
/>
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<ActivityForm
categories={planningPageData.categories}
activities={planningPageData.activities}
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
/>
<div className="space-y-5">
<ActivityForm
categories={planningPageData.categories}
activities={planningPageData.activities}
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
/>
<AdHocActivityForm
categories={planningPageData.categories}
activities={planningPageData.activities}
dailyBudget={checkInStatus?.todayCheckIn?.dailyBudget ?? null}
/>
</div>
<aside className="space-y-5">
<Card className="py-0">
@ -103,7 +111,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
<CardTitle className="text-lg text-foreground">
{planningPageData.activities.length === 0
? "Start met een eerste activiteit"
: `${planningPageData.activities.length} activiteiten ingepland`}
: `${planningPageData.activities.length} activiteiten in beeld`}
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
@ -113,12 +121,14 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
{checkInStatus?.todayCheckIn ? (
<CardDescription className="mt-3 text-sm leading-7 text-muted-foreground">
Je check-in van vandaag staat klaar met een dagbudget van{" "}
{checkInStatus.todayCheckIn.dailyBudget} punten.
{checkInStatus.todayCheckIn.dailyBudget} punten. Zowel geplande als
ongeplande activiteiten lopen mee in je dagtotaal.
</CardDescription>
) : (
<CardDescription className="mt-3 text-sm leading-7 text-muted-foreground">
Er is nog geen ochtendcheck-in van vandaag. Je kunt wel alvast plannen,
maar je budgetmeter volgt in de volgende stories.
Er is nog geen ochtendcheck-in van vandaag. Je kunt wel alvast
activiteiten vastleggen, maar je budgetmeter blijft pas echt
betekenisvol zodra je check-in er staat.
</CardDescription>
)}
</CardContent>
@ -133,9 +143,9 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
</p>
</CardHeader>
<CardContent className="space-y-3 pb-6 text-sm leading-7 text-primary-foreground/90">
<p>Deze planning blokkeert je niet en geeft nog geen harde waarschuwingen.</p>
<p>Je meter gebruikt een eenvoudige, uitlegbare afleiding uit duur en impact.</p>
<p>Bij overschrijding krijg je nu een warme, niet-blokkerende waarschuwing in plaats van een harde blokkade.</p>
<p>Deze dagweergave blokkeert je niet en geeft bewust geen harde limieten.</p>
<p>De meter gebruikt een eenvoudige, uitlegbare afleiding uit duur en impact.</p>
<p>Ook ongeplande activiteiten tellen nu mee, zodat je dagbeeld dichter bij de werkelijkheid blijft.</p>
</CardContent>
</Card>
</aside>

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">

View file

@ -92,14 +92,14 @@ Status: `ST-301`, `ST-302`, `ST-304` en `ST-305` zijn inmiddels gerealiseerd in
Doel: de kernloop afronden door geplande activiteiten te evalueren en terug te zien.
Status: `ST-401` en `ST-402` zijn inmiddels gerealiseerd in de app. De volgende
logische stap ligt nu in `ST-403`.
Status: `ST-401`, `ST-402` en `ST-403` zijn inmiddels gerealiseerd in de app. De volgende
logische stap ligt nu in `ST-404`.
| Story ID | Titel | Type | Definition of done |
| --- | --- | --- | --- |
| ST-401 | Statusflows voor uitgevoerd, geskipt en aangepast bouwen | Build | Afgerond: activiteiten van vandaag kunnen direct tussen de vier statussen wisselen |
| ST-402 | Evaluatievelden toevoegen | UI | Afgerond: skip-reden en toelichting verschijnen passend per status en worden opgeslagen |
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Ongeplande activiteit telt mee in werkelijke totalen |
| ST-403 | Ongeplande activiteiten ondersteunen | Build | Afgerond: ongeplande activiteit kan als ad-hoc item worden opgeslagen en telt mee in het dagtotaal |
| ST-404 | Dagoverzicht bouwen | UI | Gepland versus uitgevoerd en statusverdeling zijn zichtbaar |
| ST-405 | Dagaggregaties server-side implementeren | Logic | Dagtotalen blijven consistent met individuele records |

View file

@ -69,6 +69,11 @@ const planningStatusToasts: Record<string, StatusToast> = {
title: "Activiteit gepland",
message: "Je activiteit staat nu in je dagplanning van vandaag.",
},
"ad-hoc-activity-saved": {
variant: "success",
title: "Ongeplande activiteit toegevoegd",
message: "Deze activiteit staat nu ook in je daglijst van vandaag.",
},
"activity-status-saved": {
variant: "success",
title: "Activiteit bijgewerkt",
@ -88,6 +93,12 @@ const planningErrorToasts: Record<string, StatusToast> = {
message:
"Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.",
},
"invalid-ad-hoc-activity-input": {
variant: "error",
title: "Ongeplande activiteit niet opgeslagen",
message:
"Controleer naam, categorie, duur en impact en probeer het opnieuw.",
},
"invalid-activity-status": {
variant: "error",
title: "Status niet opgeslagen",
@ -109,6 +120,11 @@ const planningErrorToasts: Record<string, StatusToast> = {
title: "Evaluatie niet opgeslagen",
message: "De extra context bij deze activiteit kon niet worden opgeslagen.",
},
"ad-hoc-activity-failed": {
variant: "error",
title: "Ongeplande activiteit niet opgeslagen",
message: "De ongeplande activiteit kon niet worden toegevoegd. Probeer het opnieuw.",
},
};
export function getDashboardStatusToast(status: string | null): StatusToast | null {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
calculatePlannedPointsTotal,
calculateActivityPointsTotal,
calculatePlanningMeterSnapshot,
deriveActivityEnergyPoints,
} from "./meter";
@ -38,7 +38,7 @@ describe("deriveActivityEnergyPoints", () => {
describe("calculatePlanningMeterSnapshot", () => {
it("somt punten van activiteiten op", () => {
expect(
calculatePlannedPointsTotal([
calculateActivityPointsTotal([
{ durationMinutes: 30, impactLevel: "midden", status: "planned" },
{ durationMinutes: 90, impactLevel: "laag", status: "planned" },
]),
@ -54,7 +54,7 @@ describe("calculatePlanningMeterSnapshot", () => {
8,
);
expect(snapshot.plannedPoints).toBe(6);
expect(snapshot.totalPoints).toBe(6);
expect(snapshot.remainingBudget).toBe(2);
expect(snapshot.progressPercent).toBe(75);
expect(snapshot.isOverBudget).toBe(false);
@ -66,7 +66,7 @@ describe("calculatePlanningMeterSnapshot", () => {
null,
);
expect(snapshot.plannedPoints).toBe(2);
expect(snapshot.totalPoints).toBe(2);
expect(snapshot.dailyBudget).toBeNull();
expect(snapshot.remainingBudget).toBeNull();
expect(snapshot.progressPercent).toBeNull();
@ -81,7 +81,7 @@ describe("calculatePlanningMeterSnapshot", () => {
6,
);
expect(snapshot.plannedPoints).toBe(9);
expect(snapshot.totalPoints).toBe(9);
expect(snapshot.remainingBudget).toBe(-3);
expect(snapshot.isOverBudget).toBe(true);
expect(snapshot.progressPercent).toBe(100);

View file

@ -5,7 +5,7 @@ import type {
} from "@/lib/planning/types";
export type PlanningMeterSnapshot = {
plannedPoints: number;
totalPoints: number;
activityCount: number;
dailyBudget: number | null;
remainingBudget: number | null;
@ -59,7 +59,7 @@ export function deriveActivityEnergyPoints(input: ActivityMeterInput): number {
);
}
export function calculatePlannedPointsTotal(
export function calculateActivityPointsTotal(
activities: Pick<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
): number {
return activities.reduce(
@ -72,11 +72,11 @@ export function calculatePlanningMeterSnapshot(
activities: Pick<ActivityRecord, "durationMinutes" | "impactLevel" | "status">[],
dailyBudget: number | null,
): PlanningMeterSnapshot {
const plannedPoints = calculatePlannedPointsTotal(activities);
const totalPoints = calculateActivityPointsTotal(activities);
if (dailyBudget === null) {
return {
plannedPoints,
totalPoints,
activityCount: activities.length,
dailyBudget: null,
remainingBudget: null,
@ -86,11 +86,11 @@ export function calculatePlanningMeterSnapshot(
};
}
const remainingBudget = dailyBudget - plannedPoints;
const progressRatio = dailyBudget > 0 ? plannedPoints / dailyBudget : 0;
const remainingBudget = dailyBudget - totalPoints;
const progressRatio = dailyBudget > 0 ? totalPoints / dailyBudget : 0;
return {
plannedPoints,
totalPoints,
activityCount: activities.length,
dailyBudget,
remainingBudget,

View file

@ -1,6 +1,7 @@
import { getAuthenticatedUser } from "@/lib/auth/session";
import type {
ActivityCategory,
CreateAdHocActivitySubmission,
CreateActivitySubmission,
ActivityImpactLevel,
ActivityPriorityLevel,
@ -328,6 +329,51 @@ export async function createActivityForTodayForCurrentUser(
return mapActivityRow(data);
}
export async function createAdHocActivityForTodayForCurrentUser(
submission: CreateAdHocActivitySubmission,
): Promise<ActivityRecord> {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
const profileBundle = await ensureProfileBundleForCurrentUser();
if (!profileBundle) {
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
}
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
const supabase = await createClient();
await assertCategoryExists(supabase, submission.categoryId);
const { data, error } = await supabase
.from("activities")
.insert({
user_id: user.id,
activity_date: activityDate,
source: "ad_hoc",
status: "completed",
name: submission.name,
category_id: submission.categoryId,
duration_minutes: submission.durationMinutes,
impact_level: submission.impactLevel,
priority_level: "normaal",
skip_reason_id: null,
notes: null,
})
.select(ACTIVITY_COLUMNS)
.single();
if (error) {
throw new Error(`Ongeplande activiteit kon niet worden opgeslagen: ${error.message}`);
}
return mapActivityRow(data);
}
export async function updateActivityStatusForTodayForCurrentUser(
submission: UpdateActivityStatusSubmission,
): Promise<ActivityRecord> {

View file

@ -53,6 +53,13 @@ export type CreateActivitySubmission = {
priorityLevel: ActivityPriorityLevel;
};
export type CreateAdHocActivitySubmission = {
name: string;
categoryId: string;
durationMinutes: number;
impactLevel: ActivityImpactLevel;
};
export type UpdateActivityStatusSubmission = {
activityId: string;
status: ActivityStatus;