Implement ST-402 activity evaluation fields

This commit is contained in:
Janpeter Visser 2026-04-19 09:51:20 +02:00
parent c3cd1de647
commit 57ade6a772
10 changed files with 344 additions and 6 deletions

View file

@ -9,10 +9,12 @@ import {
} from "@/lib/planning/options";
import {
createActivityForTodayForCurrentUser,
updateActivityEvaluationForTodayForCurrentUser,
updateActivityStatusForTodayForCurrentUser,
} from "@/lib/planning/service";
import type {
CreateActivitySubmission,
UpdateActivityEvaluationSubmission,
UpdateActivityStatusSubmission,
} from "@/lib/planning/types";
import {
@ -20,6 +22,8 @@ import {
FormDataValidationError,
getEnumValue,
getIntegerValue,
getOptionalString,
getOptionalUuidValue,
getRequiredString,
getUuidValue,
} from "@/lib/forms/parse";
@ -69,6 +73,24 @@ function buildUpdateActivityStatusSubmission(
};
}
function buildUpdateActivityEvaluationSubmission(
formData: FormData,
): UpdateActivityEvaluationSubmission {
const notes = getOptionalString(formData, "notes");
return {
activityId: getUuidValue(formData, "activityId", "invalid-activity-evaluation"),
skipReasonId: getOptionalUuidValue(
formData,
"skipReasonId",
"invalid-activity-evaluation",
),
notes: notes
? assertMaxLength(notes, 500, "invalid-activity-evaluation")
: null,
};
}
export async function createActivityAction(
_previousState: null,
formData: FormData,
@ -114,3 +136,33 @@ export async function updateActivityStatusAction(
redirect(buildPathWithQuery("/planning", { status: "activity-status-saved" }));
return null;
}
export async function saveActivityEvaluationAction(
_previousState: null,
formData: FormData,
): Promise<null> {
try {
await updateActivityEvaluationForTodayForCurrentUser(
buildUpdateActivityEvaluationSubmission(formData),
);
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/planning", { error: error.code }));
}
if (
error instanceof Error &&
(error.message === "Ongeldige of niet-beschikbare activiteit." ||
error.message === "Skip-reden is verplicht voor een overgeslagen activiteit." ||
error.message === "Toelichting is verplicht voor een aangepaste activiteit." ||
error.message === "Ongeldige skip-reden.")
) {
redirect(buildPathWithQuery("/planning", { error: "invalid-activity-evaluation" }));
}
redirect(buildPathWithQuery("/planning", { error: "activity-evaluation-failed" }));
}
redirect(buildPathWithQuery("/planning", { status: "activity-evaluation-saved" }));
return null;
}

View file

@ -144,6 +144,7 @@ export default async function PlanningPage({ searchParams }: PlanningPageProps)
<TodayActivitiesList
activities={planningPageData.activities}
categories={planningPageData.categories}
skipReasons={planningPageData.skipReasons}
/>
</div>
</AppShell>

View file

@ -0,0 +1,127 @@
"use client";
import { useActionState, useState } from "react";
import { saveActivityEvaluationAction } from "@/app/planning/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ActivityStatus, SkipReason } from "@/lib/planning/types";
type ActivityEvaluationFieldsProps = {
activityId: string;
status: ActivityStatus;
skipReasons: SkipReason[];
initialSkipReasonId: string | null;
initialNotes: string | null;
};
export function ActivityEvaluationFields({
activityId,
status,
skipReasons,
initialSkipReasonId,
initialNotes,
}: ActivityEvaluationFieldsProps) {
const [, formAction, isPending] = useActionState(saveActivityEvaluationAction, null);
const [skipReasonId, setSkipReasonId] = useState(
initialSkipReasonId ?? skipReasons[0]?.id ?? "",
);
const [notes, setNotes] = useState(initialNotes ?? "");
if (status !== "skipped" && status !== "adjusted") {
return null;
}
return (
<form action={formAction} className="space-y-4" aria-busy={isPending}>
<input type="hidden" name="activityId" value={activityId} />
{status === "skipped" ? (
<>
<input type="hidden" name="skipReasonId" value={skipReasonId} />
<div className="space-y-2">
<Label className="text-sm font-semibold text-foreground">
Waarom is deze activiteit overgeslagen?
</Label>
<Select
disabled={isPending}
value={skipReasonId}
onValueChange={(value) => setSkipReasonId(value ?? skipReasons[0]?.id ?? "")}
>
<SelectTrigger className="h-11 w-full rounded-[1.15rem] bg-background/80 px-4 text-sm">
<SelectValue placeholder="Kies een skip-reden" />
</SelectTrigger>
<SelectContent>
{skipReasons.map((skipReason) => (
<SelectItem key={skipReason.id} value={skipReason.id}>
{skipReason.labelNl}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
Extra toelichting
</Label>
<textarea
id={`activity-notes-${activityId}`}
name="notes"
className="min-h-24 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
disabled={isPending}
maxLength={500}
placeholder="Optioneel: wat speelde mee?"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
</>
) : (
<div className="space-y-2">
<Label htmlFor={`activity-notes-${activityId}`} className="text-sm font-semibold text-foreground">
Wat heb je aangepast?
</Label>
<textarea
id={`activity-notes-${activityId}`}
name="notes"
className="min-h-28 w-full rounded-[1.15rem] border border-input bg-background/80 px-4 py-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
disabled={isPending}
maxLength={500}
placeholder="Beschrijf kort wat je hebt aangepast aan duur, vorm of intensiteit."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs leading-6 text-muted-foreground" aria-live="polite">
{isPending
? "Evaluatie wordt opgeslagen..."
: status === "skipped"
? "Voeg optioneel context toe, zodat later duidelijker is waarom deze activiteit niet doorging."
: "Beschrijf kort wat je hebt aangepast, zodat dagreflectie later betekenisvoller wordt."}
</p>
<Button
type="submit"
size="sm"
disabled={
isPending ||
(status === "skipped" && !skipReasonId) ||
(status === "adjusted" && !notes.trim())
}
className="rounded-full px-4"
>
{isPending ? "Evaluatie opslaan..." : "Evaluatie opslaan"}
</Button>
</div>
</form>
);
}

View file

@ -17,7 +17,7 @@ const statusOptions: Array<{
}> = [
{ value: "planned", label: "Gepland" },
{ value: "completed", label: "Uitgevoerd" },
{ value: "skipped", label: "Geschipt" },
{ value: "skipped", label: "Overgeslagen" },
{ value: "adjusted", label: "Aangepast" },
];

View file

@ -5,14 +5,20 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ActivityEvaluationFields } from "@/components/planning/activity-evaluation-fields";
import { ActivityStatusActions } from "@/components/planning/activity-status-actions";
import { deriveActivityEnergyPoints } from "@/lib/planning/meter";
import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types";
import type {
ActivityCategory,
ActivityRecord,
SkipReason,
} from "@/lib/planning/types";
import { cn } from "@/lib/utils";
type TodayActivitiesListProps = {
activities: ActivityRecord[];
categories: ActivityCategory[];
skipReasons: SkipReason[];
};
function getCategoryLabel(categories: ActivityCategory[], categoryId: string) {
@ -78,6 +84,7 @@ function getStatusBadgeClassName(value: ActivityRecord["status"]) {
export function TodayActivitiesList({
activities,
categories,
skipReasons,
}: TodayActivitiesListProps) {
return (
<Card className="py-0">
@ -141,6 +148,19 @@ export function TodayActivitiesList({
status={activity.status}
/>
</div>
{(activity.status === "skipped" || activity.status === "adjusted") ? (
<div className="mt-4 rounded-[var(--radius-lg)] border border-border/60 bg-card/60 p-4">
<ActivityEvaluationFields
key={`${activity.id}-${activity.status}`}
activityId={activity.id}
status={activity.status}
skipReasons={skipReasons}
initialSkipReasonId={activity.skipReasonId}
initialNotes={activity.notes}
/>
</div>
) : null}
</div>
))
)}

View file

@ -92,13 +92,13 @@ 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` is inmiddels gerealiseerd in de app. De volgende logische stap
ligt nu in `ST-402` en `ST-403`.
Status: `ST-401` en `ST-402` zijn inmiddels gerealiseerd in de app. De volgende
logische stap ligt nu in `ST-403`.
| 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 | Contextuele velden verschijnen passend per status |
| 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-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

@ -74,6 +74,11 @@ const planningStatusToasts: Record<string, StatusToast> = {
title: "Activiteit bijgewerkt",
message: "De status van je activiteit is opgeslagen.",
},
"activity-evaluation-saved": {
variant: "success",
title: "Evaluatie opgeslagen",
message: "De extra context bij deze activiteit is bijgewerkt.",
},
};
const planningErrorToasts: Record<string, StatusToast> = {
@ -93,6 +98,17 @@ const planningErrorToasts: Record<string, StatusToast> = {
title: "Status niet opgeslagen",
message: "De activiteitstatus kon niet worden bijgewerkt. Probeer het opnieuw.",
},
"invalid-activity-evaluation": {
variant: "error",
title: "Evaluatie niet opgeslagen",
message:
"Controleer de skip-reden of toelichting en probeer het opnieuw.",
},
"activity-evaluation-failed": {
variant: "error",
title: "Evaluatie niet opgeslagen",
message: "De extra context bij deze activiteit kon niet worden opgeslagen.",
},
};
export function getDashboardStatusToast(status: string | null): StatusToast | null {

View file

@ -169,3 +169,21 @@ export function getUuidValue(
return value;
}
export function getOptionalUuidValue(
formData: FormData,
key: string,
errorCode: string,
): string | null {
const value = getOptionalString(formData, key);
if (!value) {
return null;
}
if (!UUID_VALUE_PATTERN.test(value)) {
fail(errorCode);
}
return value;
}

View file

@ -10,6 +10,7 @@ import type {
ActivitiesForDateStatus,
ActivityStatus,
SkipReason,
UpdateActivityEvaluationSubmission,
UpdateActivityStatusSubmission,
} from "@/lib/planning/types";
import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service";
@ -166,6 +167,26 @@ async function assertCategoryExists(
}
}
async function assertSkipReasonExists(
supabase: SupabaseServerClient,
skipReasonId: string,
): Promise<void> {
const { data, error } = await supabase
.from("skip_reasons")
.select("id")
.eq("id", skipReasonId)
.eq("is_active", true)
.maybeSingle();
if (error) {
throw new Error(`Skip-reden kon niet worden gevalideerd: ${error.message}`);
}
if (!data) {
throw new Error("Ongeldige skip-reden.");
}
}
export async function listActivityCategories(): Promise<ActivityCategory[]> {
const supabase = await createClient();
const { data, error } = await supabase
@ -246,8 +267,9 @@ export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageD
return null;
}
const [categories, activitiesStatus] = await Promise.all([
const [categories, skipReasons, activitiesStatus] = await Promise.all([
listActivityCategories(),
listSkipReasons(),
getTodayActivitiesForCurrentUser(),
]);
@ -256,6 +278,7 @@ export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageD
activityDate:
activitiesStatus?.activityDate ?? getLocalDateForTimezone(profileBundle.profile.timezone),
categories,
skipReasons,
activities: activitiesStatus?.activities ?? [],
};
}
@ -344,3 +367,77 @@ export async function updateActivityStatusForTodayForCurrentUser(
return mapActivityRow(data);
}
export async function updateActivityEvaluationForTodayForCurrentUser(
submission: UpdateActivityEvaluationSubmission,
): 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();
const { data: existingActivity, error: existingActivityError } = await supabase
.from("activities")
.select(ACTIVITY_COLUMNS)
.eq("id", submission.activityId)
.eq("user_id", user.id)
.eq("activity_date", activityDate)
.maybeSingle();
if (existingActivityError) {
throw new Error(
`Activiteit voor evaluatie kon niet worden geladen: ${existingActivityError.message}`,
);
}
if (!existingActivity) {
throw new Error("Ongeldige of niet-beschikbare activiteit.");
}
let nextSkipReasonId: string | null = null;
let nextNotes: string | null = null;
if (existingActivity.status === "skipped") {
if (!submission.skipReasonId) {
throw new Error("Skip-reden is verplicht voor een overgeslagen activiteit.");
}
await assertSkipReasonExists(supabase, submission.skipReasonId);
nextSkipReasonId = submission.skipReasonId;
nextNotes = submission.notes;
} else if (existingActivity.status === "adjusted") {
if (!submission.notes) {
throw new Error("Toelichting is verplicht voor een aangepaste activiteit.");
}
nextNotes = submission.notes;
}
const { data, error } = await supabase
.from("activities")
.update({
skip_reason_id: nextSkipReasonId,
notes: nextNotes,
})
.eq("id", existingActivity.id)
.eq("user_id", user.id)
.eq("activity_date", activityDate)
.select(ACTIVITY_COLUMNS)
.single();
if (error) {
throw new Error(`Evaluatiecontext kon niet worden opgeslagen: ${error.message}`);
}
return mapActivityRow(data);
}

View file

@ -58,6 +58,12 @@ export type UpdateActivityStatusSubmission = {
status: ActivityStatus;
};
export type UpdateActivityEvaluationSubmission = {
activityId: string;
skipReasonId: string | null;
notes: string | null;
};
export type ActivitiesForDateStatus = {
timezone: string;
activityDate: string;
@ -68,5 +74,6 @@ export type PlanningPageData = {
timezone: string;
activityDate: string;
categories: ActivityCategory[];
skipReasons: SkipReason[];
activities: ActivityRecord[];
};