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>