Implement ST-302 planning form flow

This commit is contained in:
Janpeter Visser 2026-04-19 02:19:34 +02:00
parent 44bd946290
commit 5c15620993
13 changed files with 866 additions and 4 deletions

View file

@ -16,6 +16,7 @@ import { getAuthState } from "@/lib/auth/session";
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
import { getDashboardStatusToast } from "@/lib/feedback/status-messages";
import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
import { cn } from "@/lib/utils";
@ -53,7 +54,10 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
}
const { profile, settings } = profileBundle;
const checkInStatus = await getTodayCheckInForCurrentUser();
const [checkInStatus, planningStatus] = await Promise.all([
getTodayCheckInForCurrentUser(),
getTodayActivitiesForCurrentUser(),
]);
const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status"));
if (!profile.onboardingSeen) {
@ -97,6 +101,15 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
>
Instellingen
</Link>
<Link
href="/planning"
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-11 rounded-full px-5",
)}
>
Dagplanning
</Link>
{isTestWizardEnabled() ? (
<Link
href="/wizard-test"
@ -179,6 +192,35 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Dagplanning
</p>
<CardTitle className="text-lg text-slate-900">
{planningStatus?.activities.length
? `${planningStatus.activities.length} activiteiten voor vandaag`
: "Nog niets gepland 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.
</CardDescription>
<div className="mt-4">
<Link
href="/planning"
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-11 rounded-full px-5",
)}
>
Open dagplanning
</Link>
</div>
</CardContent>
</Card>
{isTestWizardEnabled() ? (
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<CardHeader className="pb-0">

71
app/planning/actions.ts Normal file
View file

@ -0,0 +1,71 @@
"use server";
import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation";
import {
ACTIVITY_IMPACT_LEVEL_VALUES,
ACTIVITY_PRIORITY_LEVEL_VALUES,
} from "@/lib/planning/options";
import { createActivityForTodayForCurrentUser } from "@/lib/planning/service";
import type { CreateActivitySubmission } from "@/lib/planning/types";
import {
assertMaxLength,
FormDataValidationError,
getEnumValue,
getIntegerValue,
getRequiredString,
getUuidValue,
} from "@/lib/forms/parse";
function buildCreateActivitySubmission(formData: FormData): CreateActivitySubmission {
const name = assertMaxLength(
getRequiredString(formData, "name", "invalid-activity-input"),
120,
"invalid-activity-input",
);
return {
name,
categoryId: getUuidValue(formData, "categoryId", "invalid-activity-input"),
durationMinutes: getIntegerValue(
formData,
"durationMinutes",
{ min: 1, max: 720 },
"invalid-activity-input",
),
impactLevel: getEnumValue(
formData,
"impactLevel",
ACTIVITY_IMPACT_LEVEL_VALUES,
"invalid-activity-input",
),
priorityLevel: getEnumValue(
formData,
"priorityLevel",
ACTIVITY_PRIORITY_LEVEL_VALUES,
"invalid-activity-input",
),
};
}
export async function createActivityAction(
_previousState: null,
formData: FormData,
): Promise<null> {
try {
await createActivityForTodayForCurrentUser(buildCreateActivitySubmission(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-activity-input" }));
}
throw error;
}
redirect(buildPathWithQuery("/planning", { status: "activity-saved" }));
return null;
}

162
app/planning/page.tsx Normal file
View file

@ -0,0 +1,162 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { signOutAction } from "@/app/auth-actions";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { ActivityForm } from "@/components/planning/activity-form";
import { TodayActivitiesList } from "@/components/planning/today-activities-list";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
import { getPlanningStatusToast } from "@/lib/feedback/status-messages";
import { getPlanningPageDataForCurrentUser } from "@/lib/planning/service";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
type PlanningPageProps = {
searchParams: Promise<PageSearchParams>;
};
export default async function PlanningPage({ searchParams }: PlanningPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
}
if (!authState.isAuthenticated) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
}
const profileBundle = await getProfileBundleForCurrentUser();
if (!profileBundle) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
}
if (!profileBundle.profile.onboardingSeen) {
redirect("/onboarding");
}
const [planningPageData, checkInStatus] = await Promise.all([
getPlanningPageDataForCurrentUser(),
getTodayCheckInForCurrentUser(),
]);
if (!planningPageData) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/planning"))}`);
}
const statusToast = getPlanningStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
<div>
<div className="flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
<Link href="/dashboard" className="transition hover:text-slate-900">
Dashboard
</Link>
<span>/</span>
<span>Dagplanning</span>
</div>
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
Plan vandaag bewust klein
</h1>
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
Voeg alleen activiteiten toe die vandaag echt relevant zijn. Houd de lijst licht,
zodat je later goed kunt bijsturen zonder druk op te bouwen.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/dashboard"
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-11 rounded-full px-5",
)}
>
Terug naar dashboard
</Link>
<form action={signOutAction}>
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
Uitloggen
</Button>
</form>
</div>
</header>
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<ActivityForm categories={planningPageData.categories} />
<aside className="space-y-5">
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Vandaag
</p>
<CardTitle className="text-lg text-slate-900">
{planningPageData.activities.length === 0
? "Start met een eerste activiteit"
: `${planningPageData.activities.length} activiteiten ingepland`}
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<CardDescription className="text-sm leading-7 text-muted-foreground">
Lokale datum: {planningPageData.activityDate} in timezone `{planningPageData.timezone}`.
</CardDescription>
{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.
</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.
</CardDescription>
)}
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-primary/15 bg-primary py-0 text-primary-foreground shadow-[0_12px_40px_rgba(22,58,43,0.18)]">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/75">
Bewuste grens
</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 legt nu alleen vast wat je wilt doen, hoe lang het duurt en hoe zwaar het voelt.</p>
<p>De meter en budgetfeedback volgen in `ST-304`.</p>
</CardContent>
</Card>
</aside>
</section>
<TodayActivitiesList
activities={planningPageData.activities}
categories={planningPageData.categories}
/>
</div>
</main>
);
}