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

@ -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 {
if (!status) {
return null;
@ -116,6 +133,21 @@ export function getCheckInStatusToast(
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(
error: string | null,
status: string | null,

View file

@ -1,6 +1,8 @@
const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
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 {
code: string;
@ -117,6 +119,18 @@ export function assertMinLength(
return value;
}
export function assertMaxLength(
value: string,
maximumLength: number,
errorCode: string,
): string {
if (value.length > maximumLength) {
fail(errorCode);
}
return value;
}
export function getIntegerValue(
formData: FormData,
key: string,
@ -141,3 +155,17 @@ export function getIntegerValue(
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;
}

View 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;

View file

@ -1,8 +1,10 @@
import { getAuthenticatedUser } from "@/lib/auth/session";
import type {
ActivityCategory,
CreateActivitySubmission,
ActivityImpactLevel,
ActivityPriorityLevel,
PlanningPageData,
ActivityRecord,
ActivitySource,
ActivitiesForDateStatus,
@ -143,6 +145,26 @@ async function readActivitiesByDate(
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[]> {
const supabase = await createClient();
const { data, error } = await supabase
@ -209,3 +231,75 @@ export async function getTodayActivitiesForCurrentUser(): Promise<ActivitiesForD
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);
}

View file

@ -45,8 +45,23 @@ export type ActivityRecord = {
updatedAt: string;
};
export type CreateActivitySubmission = {
name: string;
categoryId: string;
durationMinutes: number;
impactLevel: ActivityImpactLevel;
priorityLevel: ActivityPriorityLevel;
};
export type ActivitiesForDateStatus = {
timezone: string;
activityDate: string;
activities: ActivityRecord[];
};
export type PlanningPageData = {
timezone: string;
activityDate: string;
categories: ActivityCategory[];
activities: ActivityRecord[];
};