Implement ST-302 planning form flow
This commit is contained in:
parent
44bd946290
commit
5c15620993
13 changed files with 866 additions and 4 deletions
|
|
@ -21,6 +21,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal:
|
||||||
- eenvoudig dagbudget en energieniveau op basis van de ochtendscore
|
- eenvoudig dagbudget en energieniveau op basis van de ochtendscore
|
||||||
- dashboardweergave van check-instatus, energieniveau en dagbudget
|
- dashboardweergave van check-instatus, energieniveau en dagbudget
|
||||||
- planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase
|
- planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase
|
||||||
|
- planningpagina voor vandaag met activiteit toevoegen en directe lijstweergave
|
||||||
- eerste unit tests voor budgetmapping via `Vitest`
|
- eerste unit tests voor budgetmapping via `Vitest`
|
||||||
- korte onboardingflow voor eerste voorkeuren
|
- korte onboardingflow voor eerste voorkeuren
|
||||||
- instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten
|
- instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten
|
||||||
|
|
@ -111,7 +112,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat.
|
||||||
|
|
||||||
## Eerstvolgende bouwstappen
|
## Eerstvolgende bouwstappen
|
||||||
|
|
||||||
1. `ST-302` Planningformulier bouwen
|
1. `ST-303` Autocomplete op eerdere activiteiten toevoegen
|
||||||
2. `ST-304` EnergyMeter en lopend totaal implementeren
|
2. `ST-304` EnergyMeter en lopend totaal implementeren
|
||||||
3. `ST-401` Evaluatie- en dagoverzichtslus bouwen
|
3. `ST-401` Evaluatie- en dagoverzichtslus bouwen
|
||||||
4. `ST-105` RLS-policy tests en hardening afronden
|
4. `ST-105` RLS-policy tests en hardening afronden
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { getAuthState } from "@/lib/auth/session";
|
||||||
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service";
|
||||||
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
|
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
|
||||||
import { getDashboardStatusToast } from "@/lib/feedback/status-messages";
|
import { getDashboardStatusToast } from "@/lib/feedback/status-messages";
|
||||||
|
import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service";
|
||||||
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
|
||||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -53,7 +54,10 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
}
|
}
|
||||||
|
|
||||||
const { profile, settings } = profileBundle;
|
const { profile, settings } = profileBundle;
|
||||||
const checkInStatus = await getTodayCheckInForCurrentUser();
|
const [checkInStatus, planningStatus] = await Promise.all([
|
||||||
|
getTodayCheckInForCurrentUser(),
|
||||||
|
getTodayActivitiesForCurrentUser(),
|
||||||
|
]);
|
||||||
const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status"));
|
const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status"));
|
||||||
|
|
||||||
if (!profile.onboardingSeen) {
|
if (!profile.onboardingSeen) {
|
||||||
|
|
@ -97,6 +101,15 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
>
|
>
|
||||||
Instellingen
|
Instellingen
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/planning"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "lg" }),
|
||||||
|
"h-11 rounded-full px-5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Dagplanning
|
||||||
|
</Link>
|
||||||
{isTestWizardEnabled() ? (
|
{isTestWizardEnabled() ? (
|
||||||
<Link
|
<Link
|
||||||
href="/wizard-test"
|
href="/wizard-test"
|
||||||
|
|
@ -179,6 +192,35 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
|
||||||
|
|
||||||
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
<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() ? (
|
{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)]">
|
<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">
|
<CardHeader className="pb-0">
|
||||||
|
|
|
||||||
71
app/planning/actions.ts
Normal file
71
app/planning/actions.ts
Normal 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
162
app/planning/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
components/planning/activity-form.tsx
Normal file
266
components/planning/activity-form.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { createActivityAction } from "@/app/planning/actions";
|
||||||
|
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 { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
ACTIVITY_DURATION_SUGGESTIONS,
|
||||||
|
ACTIVITY_IMPACT_OPTIONS,
|
||||||
|
ACTIVITY_PRIORITY_OPTIONS,
|
||||||
|
} from "@/lib/planning/form-options";
|
||||||
|
import type { ActivityCategory } from "@/lib/planning/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ActivityFormProps = {
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityForm({ categories }: ActivityFormProps) {
|
||||||
|
const [, formAction, isPending] = useActionState(createActivityAction, 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 [priorityLevel, setPriorityLevel] = useState<"laag" | "normaal" | "hoog">("normaal");
|
||||||
|
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() => categories.find((category) => category.id === categoryId) ?? null,
|
||||||
|
[categories, categoryId],
|
||||||
|
);
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<input type="hidden" name="priorityLevel" value={priorityLevel} />
|
||||||
|
|
||||||
|
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.1)]">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Dagplanning
|
||||||
|
</p>
|
||||||
|
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-slate-900">
|
||||||
|
Plan een activiteit voor vandaag
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
Houd het klein en concreet. Je legt alleen de basis vast: wat je wilt doen,
|
||||||
|
hoe lang het ongeveer duurt en hoe zwaar het aanvoelt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pb-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="activity-name" className="text-slate-800">
|
||||||
|
Naam van de activiteit
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="activity-name"
|
||||||
|
name="name"
|
||||||
|
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base"
|
||||||
|
disabled={isPending}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Bijvoorbeeld: was opvouwen"
|
||||||
|
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-slate-800">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="duration-minutes" className="text-slate-800">
|
||||||
|
Geschatte duur in minuten
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="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">
|
||||||
|
{ACTIVITY_DURATION_SUGGESTIONS.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setDurationMinutes(String(value))}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: durationMinutes === String(value) ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"rounded-full px-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value} min
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold text-slate-900">
|
||||||
|
Verwachte impact
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Kies hoe belastend deze activiteit voor jou aanvoelt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{ACTIVITY_IMPACT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = impactLevel === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setImpactLevel(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary text-primary-foreground shadow-[0_12px_30px_rgba(22,58,43,0.18)]"
|
||||||
|
: "border-border/60 bg-background/80 text-slate-900 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>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm font-semibold text-slate-900">
|
||||||
|
Prioriteit voor vandaag
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
Dit helpt straks om bewust te herschikken zonder alles te verliezen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{ACTIVITY_PRIORITY_OPTIONS.map((option) => {
|
||||||
|
const isSelected = priorityLevel === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setPriorityLevel(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.25rem] border px-4 py-4 text-left transition",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary text-primary-foreground shadow-[0_12px_30px_rgba(22,58,43,0.18)]"
|
||||||
|
: "border-border/60 bg-background/80 text-slate-900 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>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm leading-7 text-muted-foreground">
|
||||||
|
{isPending
|
||||||
|
? "Je activiteit wordt opgeslagen..."
|
||||||
|
: "Je activiteit wordt vandaag toegevoegd met status `gepland`."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
!name.trim() ||
|
||||||
|
!categoryId ||
|
||||||
|
!durationMinutes.trim()
|
||||||
|
}
|
||||||
|
className="h-11 rounded-full px-5"
|
||||||
|
>
|
||||||
|
{isPending ? "Activiteit opslaan..." : "Plan activiteit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
components/planning/today-activities-list.tsx
Normal file
99
components/planning/today-activities-list.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { ActivityCategory, ActivityRecord } from "@/lib/planning/types";
|
||||||
|
|
||||||
|
type TodayActivitiesListProps = {
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCategoryLabel(categories: ActivityCategory[], categoryId: string) {
|
||||||
|
return categories.find((category) => category.id === categoryId)?.labelNl ?? "Onbekende categorie";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpactLabel(value: ActivityRecord["impactLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Laag";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "midden") {
|
||||||
|
return "Midden";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Hoog";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriorityLabel(value: ActivityRecord["priorityLevel"]) {
|
||||||
|
if (value === "laag") {
|
||||||
|
return "Laag";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "hoog") {
|
||||||
|
return "Hoog";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Normaal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodayActivitiesList({
|
||||||
|
activities,
|
||||||
|
categories,
|
||||||
|
}: TodayActivitiesListProps) {
|
||||||
|
return (
|
||||||
|
<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 gepland
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-lg text-slate-900">
|
||||||
|
{activities.length === 0
|
||||||
|
? "Nog geen activiteiten gepland"
|
||||||
|
: `${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.
|
||||||
|
</CardDescription>
|
||||||
|
) : (
|
||||||
|
activities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="rounded-[1.25rem] border border-border/60 bg-background/80 px-4 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{activity.name}</p>
|
||||||
|
<p className="mt-1 text-sm leading-7 text-muted-foreground">
|
||||||
|
{getCategoryLabel(categories, activity.categoryId)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-secondary px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-secondary-foreground">
|
||||||
|
Gepland
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 text-sm leading-7 text-slate-700 sm:grid-cols-3">
|
||||||
|
<p>
|
||||||
|
<strong>Duur:</strong> {activity.durationMinutes} min
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Impact:</strong> {formatImpactLabel(activity.impactLevel)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Prioriteit:</strong> {formatPriorityLabel(activity.priorityLevel)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem
|
||||||
- Ochtendcheck-in slaat nu energiescore en slaapkwaliteit per dag op
|
- Ochtendcheck-in slaat nu energiescore en slaapkwaliteit per dag op
|
||||||
- Dagbudget v1 is bewust eenvoudig: `daily_budget = energy_score`
|
- Dagbudget v1 is bewust eenvoudig: `daily_budget = energy_score`
|
||||||
- Energieniveau en budget worden al direct getoond in check-in en dashboard
|
- Energieniveau en budget worden al direct getoond in check-in en dashboard
|
||||||
- `ST-301` legt nu ook het activiteitenmodel, categorieën en skip-redenen vast
|
- `ST-301` en `ST-302` leggen nu ook het activiteitenmodel en de eerste dagplanningflow vast
|
||||||
- Eerste unit tests voor budgetmapping draaien via `Vitest`
|
- Eerste unit tests voor budgetmapping draaien via `Vitest`
|
||||||
|
|
||||||
## Generator
|
## Generator
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,12 @@ Status: `ST-201`, `ST-202`, `ST-203`, `ST-204` en `ST-205` zijn inmiddels gereal
|
||||||
|
|
||||||
Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel.
|
Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig energiemodel.
|
||||||
|
|
||||||
|
Status: `ST-301` en `ST-302` zijn inmiddels gerealiseerd in de app. De volgende logische stap ligt nu in `ST-303` en `ST-304`.
|
||||||
|
|
||||||
| Story ID | Titel | Type | Definition of done |
|
| Story ID | Titel | Type | Definition of done |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| ST-301 | Datamodel voor activiteiten implementeren | Build | Afgerond: migraties en seed-data voor categorieën en skip-redenen zijn aanwezig |
|
| ST-301 | Datamodel voor activiteiten implementeren | Build | Afgerond: migraties en seed-data voor categorieën en skip-redenen zijn aanwezig |
|
||||||
| ST-302 | Planningformulier bouwen | UI | Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt |
|
| ST-302 | Planningformulier bouwen | UI | Afgerond: activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt |
|
||||||
| ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen |
|
| ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen |
|
||||||
| ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging |
|
| ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging |
|
||||||
| ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Gebruiker krijgt feedback maar behoudt regie |
|
| ST-305 | Overschrijdingswaarschuwing toevoegen | UX | Gebruiker krijgt feedback maar behoudt regie |
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,23 @@ const checkInErrorToasts: Record<string, StatusToast> = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const planningStatusToasts: Record<string, StatusToast> = {
|
||||||
|
"activity-saved": {
|
||||||
|
variant: "success",
|
||||||
|
title: "Activiteit gepland",
|
||||||
|
message: "Je activiteit staat nu in je dagplanning van vandaag.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const planningErrorToasts: Record<string, StatusToast> = {
|
||||||
|
"invalid-activity-input": {
|
||||||
|
variant: "error",
|
||||||
|
title: "Activiteit niet opgeslagen",
|
||||||
|
message:
|
||||||
|
"Controleer naam, categorie, duur, impact en prioriteit en probeer het opnieuw.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function getDashboardStatusToast(status: string | null): StatusToast | null {
|
export function getDashboardStatusToast(status: string | null): StatusToast | null {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -116,6 +133,21 @@ export function getCheckInStatusToast(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPlanningStatusToast(
|
||||||
|
error: string | null,
|
||||||
|
status: string | null,
|
||||||
|
): StatusToast | null {
|
||||||
|
if (error && planningErrorToasts[error]) {
|
||||||
|
return planningErrorToasts[error];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return planningStatusToasts[status] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAuthStatusToast(
|
export function getAuthStatusToast(
|
||||||
error: string | null,
|
error: string | null,
|
||||||
status: string | null,
|
status: string | null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
const INTEGER_VALUE_PATTERN = /^-?\d+$/;
|
const INTEGER_VALUE_PATTERN = /^-?\d+$/;
|
||||||
|
const UUID_VALUE_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
export class FormDataValidationError extends Error {
|
export class FormDataValidationError extends Error {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
@ -117,6 +119,18 @@ export function assertMinLength(
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertMaxLength(
|
||||||
|
value: string,
|
||||||
|
maximumLength: number,
|
||||||
|
errorCode: string,
|
||||||
|
): string {
|
||||||
|
if (value.length > maximumLength) {
|
||||||
|
fail(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function getIntegerValue(
|
export function getIntegerValue(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
key: string,
|
key: string,
|
||||||
|
|
@ -141,3 +155,17 @@ export function getIntegerValue(
|
||||||
|
|
||||||
return parsedValue;
|
return parsedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUuidValue(
|
||||||
|
formData: FormData,
|
||||||
|
key: string,
|
||||||
|
errorCode: string,
|
||||||
|
): string {
|
||||||
|
const value = getRequiredString(formData, key, errorCode);
|
||||||
|
|
||||||
|
if (!UUID_VALUE_PATTERN.test(value)) {
|
||||||
|
fail(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
|
||||||
50
lib/planning/form-options.ts
Normal file
50
lib/planning/form-options.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type {
|
||||||
|
ActivityImpactLevel,
|
||||||
|
ActivityPriorityLevel,
|
||||||
|
} from "@/lib/planning/types";
|
||||||
|
|
||||||
|
export const ACTIVITY_DURATION_SUGGESTIONS = [15, 30, 45, 60, 90, 120] as const;
|
||||||
|
|
||||||
|
export const ACTIVITY_IMPACT_OPTIONS: ReadonlyArray<{
|
||||||
|
value: ActivityImpactLevel;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "laag",
|
||||||
|
label: "Laag",
|
||||||
|
description: "Kleine activiteit met beperkte verwachte belasting.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "midden",
|
||||||
|
label: "Midden",
|
||||||
|
description: "Merkbare activiteit waarvoor je bewust ruimte wilt houden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hoog",
|
||||||
|
label: "Hoog",
|
||||||
|
description: "Zwaardere activiteit die waarschijnlijk meer herstel vraagt.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ACTIVITY_PRIORITY_OPTIONS: ReadonlyArray<{
|
||||||
|
value: ActivityPriorityLevel;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "laag",
|
||||||
|
label: "Laag",
|
||||||
|
description: "Kan makkelijk verschuiven als je dag anders loopt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "normaal",
|
||||||
|
label: "Normaal",
|
||||||
|
description: "Belangrijk genoeg om bewust in je dag mee te nemen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "hoog",
|
||||||
|
label: "Hoog",
|
||||||
|
description: "Heeft vandaag duidelijk prioriteit, maar blijft wel een keuze.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { getAuthenticatedUser } from "@/lib/auth/session";
|
import { getAuthenticatedUser } from "@/lib/auth/session";
|
||||||
import type {
|
import type {
|
||||||
ActivityCategory,
|
ActivityCategory,
|
||||||
|
CreateActivitySubmission,
|
||||||
ActivityImpactLevel,
|
ActivityImpactLevel,
|
||||||
ActivityPriorityLevel,
|
ActivityPriorityLevel,
|
||||||
|
PlanningPageData,
|
||||||
ActivityRecord,
|
ActivityRecord,
|
||||||
ActivitySource,
|
ActivitySource,
|
||||||
ActivitiesForDateStatus,
|
ActivitiesForDateStatus,
|
||||||
|
|
@ -143,6 +145,26 @@ async function readActivitiesByDate(
|
||||||
return (data ?? []).map(mapActivityRow);
|
return (data ?? []).map(mapActivityRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertCategoryExists(
|
||||||
|
supabase: SupabaseServerClient,
|
||||||
|
categoryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("activity_categories")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", categoryId)
|
||||||
|
.eq("is_active", true)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Activiteitcategorie kon niet worden gevalideerd: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Ongeldige activiteitcategorie.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listActivityCategories(): Promise<ActivityCategory[]> {
|
export async function listActivityCategories(): Promise<ActivityCategory[]> {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
|
|
@ -209,3 +231,75 @@ export async function getTodayActivitiesForCurrentUser(): Promise<ActivitiesForD
|
||||||
activities,
|
activities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageData | null> {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
||||||
|
|
||||||
|
if (!profileBundle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categories, activitiesStatus] = await Promise.all([
|
||||||
|
listActivityCategories(),
|
||||||
|
getTodayActivitiesForCurrentUser(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timezone: profileBundle.profile.timezone,
|
||||||
|
activityDate:
|
||||||
|
activitiesStatus?.activityDate ?? getLocalDateForTimezone(profileBundle.profile.timezone),
|
||||||
|
categories,
|
||||||
|
activities: activitiesStatus?.activities ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActivityForTodayForCurrentUser(
|
||||||
|
submission: CreateActivitySubmission,
|
||||||
|
): 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: "planned",
|
||||||
|
status: "planned",
|
||||||
|
name: submission.name,
|
||||||
|
category_id: submission.categoryId,
|
||||||
|
duration_minutes: submission.durationMinutes,
|
||||||
|
impact_level: submission.impactLevel,
|
||||||
|
priority_level: submission.priorityLevel,
|
||||||
|
skip_reason_id: null,
|
||||||
|
notes: null,
|
||||||
|
})
|
||||||
|
.select(ACTIVITY_COLUMNS)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Activiteit kon niet worden opgeslagen: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapActivityRow(data);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,23 @@ export type ActivityRecord = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateActivitySubmission = {
|
||||||
|
name: string;
|
||||||
|
categoryId: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
impactLevel: ActivityImpactLevel;
|
||||||
|
priorityLevel: ActivityPriorityLevel;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActivitiesForDateStatus = {
|
export type ActivitiesForDateStatus = {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
activityDate: string;
|
activityDate: string;
|
||||||
activities: ActivityRecord[];
|
activities: ActivityRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlanningPageData = {
|
||||||
|
timezone: string;
|
||||||
|
activityDate: string;
|
||||||
|
categories: ActivityCategory[];
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue