Implement ST-302 planning form flow
This commit is contained in:
parent
44bd946290
commit
5c15620993
13 changed files with 866 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue