From f5b459dadb6a13849cc7564f18e7e5281af6c313 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 18 Apr 2026 18:42:31 +0200 Subject: [PATCH] Implement ST-201 morning check-in flow --- README.md | 7 +- app/check-in/actions.ts | 47 +++++ app/check-in/page.tsx | 135 +++++++++++++ app/dashboard/page.tsx | 5 + components/check-in/check-in-card.tsx | 56 ++++++ components/check-in/check-in-form.tsx | 178 ++++++++++++++++++ lib/check-in/options.ts | 21 +++ lib/check-in/service.ts | 146 ++++++++++++++ lib/check-in/types.ts | 22 +++ lib/feedback/status-messages.ts | 28 +++ lib/forms/parse.ts | 26 +++ .../20260418_create_morning_check_ins.sql | 42 +++++ 12 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 app/check-in/actions.ts create mode 100644 app/check-in/page.tsx create mode 100644 components/check-in/check-in-card.tsx create mode 100644 components/check-in/check-in-form.tsx create mode 100644 lib/check-in/options.ts create mode 100644 lib/check-in/service.ts create mode 100644 lib/check-in/types.ts create mode 100644 supabase/migrations/20260418_create_morning_check_ins.sql diff --git a/README.md b/README.md index 3f99b90..e809929 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - e-mail/wachtwoord-auth via Supabase - protected dashboard met server-side sessiecontrole +- ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen @@ -101,7 +102,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-201` Ochtendcheck-in UI bouwen -2. `ST-203` Budgetlogica implementeren -3. `ST-301` Activiteitenmodel en planning opzetten +1. `ST-203` Budgetlogica implementeren +2. `ST-301` Activiteitenmodel en planning opzetten +3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/app/check-in/actions.ts b/app/check-in/actions.ts new file mode 100644 index 0000000..6e21b7b --- /dev/null +++ b/app/check-in/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { SLEEP_QUALITY_VALUES } from "@/lib/check-in/options"; +import { upsertTodayCheckInForCurrentUser } from "@/lib/check-in/service"; +import type { MorningCheckInSubmission } from "@/lib/check-in/types"; +import { + FormDataValidationError, + getEnumValue, + getIntegerValue, +} from "@/lib/forms/parse"; + +function buildMorningCheckInSubmission(formData: FormData): MorningCheckInSubmission { + return { + energyScore: getIntegerValue( + formData, + "energyScore", + { min: 1, max: 10 }, + "invalid-check-in-input", + ), + sleepQuality: getEnumValue( + formData, + "sleepQuality", + SLEEP_QUALITY_VALUES, + "invalid-check-in-input", + ), + }; +} + +export async function saveMorningCheckInAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await upsertTodayCheckInForCurrentUser(buildMorningCheckInSubmission(formData)); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/check-in", { error: error.code })); + } + + throw error; + } + + redirect(buildPathWithQuery("/dashboard", { status: "check-in-saved" })); + return null; +} diff --git a/app/check-in/page.tsx b/app/check-in/page.tsx new file mode 100644 index 0000000..484352b --- /dev/null +++ b/app/check-in/page.tsx @@ -0,0 +1,135 @@ +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 { CheckInForm } from "@/components/check-in/check-in-form"; +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 { getCheckInStatusToast } from "@/lib/feedback/status-messages"; +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 CheckInPageProps = { + searchParams: Promise; +}; + +export default async function CheckInPage({ searchParams }: CheckInPageProps) { + 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("/check-in"))}`); + } + + const profileBundle = await getProfileBundleForCurrentUser(); + + if (!profileBundle) { + redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/check-in"))}`); + } + + if (!profileBundle.profile.onboardingSeen) { + redirect("/onboarding"); + } + + const checkInStatus = await getTodayCheckInForCurrentUser(); + const statusToast = getCheckInStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); + + return ( +
+
+ + +
+
+
+ + Dashboard + + / + Ochtendcheck-in +
+

+ Ochtendcheck-in van vandaag +

+

+ Houd je start rustig en klein. Je legt alleen een energiescore en een + globale slaapindruk vast voor vandaag. +

+
+ +
+ + Terug naar dashboard + +
+ +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index dd53b1f..b1071c5 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; +import { CheckInCard } from "@/components/check-in/check-in-card"; import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -12,6 +13,7 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; 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 { getProfileBundleForCurrentUser } from "@/lib/profile/service"; @@ -51,6 +53,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; + const checkInStatus = await getTodayCheckInForCurrentUser(); const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { @@ -174,6 +177,8 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps + + {isTestWizardEnabled() ? ( diff --git a/components/check-in/check-in-card.tsx b/components/check-in/check-in-card.tsx new file mode 100644 index 0000000..b815b59 --- /dev/null +++ b/components/check-in/check-in-card.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { MorningCheckInRecord } from "@/lib/check-in/types"; +import { cn } from "@/lib/utils"; + +type CheckInCardProps = { + todayCheckIn: MorningCheckInRecord | null; +}; + +function formatSleepQualityLabel(value: MorningCheckInRecord["sleepQuality"]) { + if (value === "goed") { + return "Goed"; + } + + if (value === "matig") { + return "Matig"; + } + + return "Slecht"; +} + +export function CheckInCard({ todayCheckIn }: CheckInCardProps) { + const title = todayCheckIn ? "Vandaag ingevuld" : "Nog niet ingevuld"; + const description = todayCheckIn + ? `Energie ${todayCheckIn.energyScore}/10, slaap ${formatSleepQualityLabel(todayCheckIn.sleepQuality).toLowerCase()}.` + : "Leg je energiestart en slaapkwaliteit van vandaag vast."; + + return ( + + +

+ Ochtendcheck-in +

+ {title} +
+ + + {description} + + + {todayCheckIn ? "Werk check-in bij" : "Start check-in"} + + +
+ ); +} diff --git a/components/check-in/check-in-form.tsx b/components/check-in/check-in-form.tsx new file mode 100644 index 0000000..c27d1f6 --- /dev/null +++ b/components/check-in/check-in-form.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { saveMorningCheckInAction } from "@/app/check-in/actions"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { ENERGY_SCORE_VALUES, SLEEP_QUALITY_OPTIONS } from "@/lib/check-in/options"; +import type { + MorningCheckInRecord, + SleepQuality, +} from "@/lib/check-in/types"; +import { cn } from "@/lib/utils"; + +type CheckInFormProps = { + todayCheckIn: MorningCheckInRecord | null; +}; + +function getEnergyScorePrompt(score: number | null) { + if (score === null) { + return "Kies hoe je energiestart vandaag voelt op een schaal van 1 tot 10."; + } + + if (score <= 3) { + return "Rustig aan is vandaag waarschijnlijk extra belangrijk."; + } + + if (score <= 7) { + return "Je start voelt gematigd; plan bewust en houd ruimte over."; + } + + return "Je start voelt relatief sterk; hou nog steeds een rustige marge aan."; +} + +export function CheckInForm({ todayCheckIn }: CheckInFormProps) { + const [, formAction, isPending] = useActionState(saveMorningCheckInAction, null); + const [energyScore, setEnergyScore] = useState( + todayCheckIn?.energyScore ?? null, + ); + const [sleepQuality, setSleepQuality] = useState( + todayCheckIn?.sleepQuality ?? null, + ); + + return ( +
+ + + + + +

+ Ochtendcheck-in +

+ + Hoe start je vandaag? + + + Houd deze check-in klein. Je legt alleen vast hoe je energie en slaap + vandaag voelen, zodat de volgende stories daarop kunnen voortbouwen. + +
+ +
+
+ +

+ {getEnergyScorePrompt(energyScore)} +

+
+ +
+ {ENERGY_SCORE_VALUES.map((value) => { + const isSelected = energyScore === value; + + return ( + + ); + })} +
+
+ + + +
+
+ +

+ Eén globale indruk is genoeg voor deze eerste release. +

+
+ +
+ {SLEEP_QUALITY_OPTIONS.map((option) => { + const isSelected = sleepQuality === option.value; + + return ( + + ); + })} +
+
+
+
+ +
+

+ {isPending + ? "Je ochtendcheck-in wordt opgeslagen..." + : todayCheckIn + ? "Je kunt de check-in van vandaag nog aanpassen." + : "Je vult voor vandaag één check-in in, die je later nog kunt aanpassen."} +

+ + +
+
+ ); +} diff --git a/lib/check-in/options.ts b/lib/check-in/options.ts new file mode 100644 index 0000000..c11d9fd --- /dev/null +++ b/lib/check-in/options.ts @@ -0,0 +1,21 @@ +export const SLEEP_QUALITY_OPTIONS = [ + { + value: "goed", + label: "Goed", + description: "Je bent redelijk uitgerust wakker geworden.", + }, + { + value: "matig", + label: "Matig", + description: "Je slaap was onrustig of niet helemaal herstellend.", + }, + { + value: "slecht", + label: "Slecht", + description: "Je voelt dat je slaap duidelijk onvoldoende hielp.", + }, +] as const; + +export const SLEEP_QUALITY_VALUES = SLEEP_QUALITY_OPTIONS.map((option) => option.value); + +export const ENERGY_SCORE_VALUES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; diff --git a/lib/check-in/service.ts b/lib/check-in/service.ts new file mode 100644 index 0000000..99c7489 --- /dev/null +++ b/lib/check-in/service.ts @@ -0,0 +1,146 @@ +import { getAuthenticatedUser } from "@/lib/auth/session"; +import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { createClient } from "@/lib/supabase/server"; +import type { + MorningCheckInRecord, + MorningCheckInStatus, + MorningCheckInSubmission, + SleepQuality, +} from "@/lib/check-in/types"; + +type SupabaseServerClient = Awaited>; + +type MorningCheckInRow = { + id: string; + user_id: string; + check_in_date: string; + energy_score: number; + sleep_quality: SleepQuality; + created_at: string; + updated_at: string; +}; + +type MorningCheckInInsert = { + user_id: string; + check_in_date: string; + energy_score: number; + sleep_quality: SleepQuality; +}; + +const MORNING_CHECK_IN_COLUMNS = + "id, user_id, check_in_date, energy_score, sleep_quality, created_at, updated_at"; + +function mapMorningCheckInRow(row: MorningCheckInRow): MorningCheckInRecord { + return { + id: row.id, + userId: row.user_id, + checkInDate: row.check_in_date, + energyScore: row.energy_score, + sleepQuality: row.sleep_quality, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getLocalDateForTimezone(timezone: string, date = new Date()) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const parts = formatter.formatToParts(date); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const day = parts.find((part) => part.type === "day")?.value; + + if (!year || !month || !day) { + throw new Error("Lokale datum voor timezone kon niet worden bepaald."); + } + + return `${year}-${month}-${day}`; +} + +async function readMorningCheckInByDate( + supabase: SupabaseServerClient, + userId: string, + checkInDate: string, +): Promise { + const { data, error } = await supabase + .from("morning_check_ins") + .select(MORNING_CHECK_IN_COLUMNS) + .eq("user_id", userId) + .eq("check_in_date", checkInDate) + .maybeSingle(); + + if (error) { + throw new Error(`Ochtendcheck-in kon niet worden geladen: ${error.message}`); + } + + return data; +} + +export async function getTodayCheckInForCurrentUser(): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + return null; + } + + const timezone = profileBundle.profile.timezone; + const todayDate = getLocalDateForTimezone(timezone); + const supabase = await createClient(); + const morningCheckInRow = await readMorningCheckInByDate(supabase, user.id, todayDate); + + return { + timezone, + todayDate, + todayCheckIn: morningCheckInRow ? mapMorningCheckInRow(morningCheckInRow) : null, + }; +} + +export async function upsertTodayCheckInForCurrentUser( + submission: MorningCheckInSubmission, +): Promise { + 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 checkInDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const payload: MorningCheckInInsert = { + user_id: user.id, + check_in_date: checkInDate, + energy_score: submission.energyScore, + sleep_quality: submission.sleepQuality, + }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("morning_check_ins") + .upsert(payload, { + onConflict: "user_id,check_in_date", + }) + .select(MORNING_CHECK_IN_COLUMNS) + .single(); + + if (error) { + throw new Error(`Ochtendcheck-in kon niet worden opgeslagen: ${error.message}`); + } + + return mapMorningCheckInRow(data); +} diff --git a/lib/check-in/types.ts b/lib/check-in/types.ts new file mode 100644 index 0000000..10f6b56 --- /dev/null +++ b/lib/check-in/types.ts @@ -0,0 +1,22 @@ +export type SleepQuality = "goed" | "matig" | "slecht"; + +export type MorningCheckInRecord = { + id: string; + userId: string; + checkInDate: string; + energyScore: number; + sleepQuality: SleepQuality; + createdAt: string; + updatedAt: string; +}; + +export type MorningCheckInSubmission = { + energyScore: number; + sleepQuality: SleepQuality; +}; + +export type MorningCheckInStatus = { + timezone: string; + todayDate: string; + todayCheckIn: MorningCheckInRecord | null; +}; diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts index bcf955d..5bcd8ab 100644 --- a/lib/feedback/status-messages.ts +++ b/lib/feedback/status-messages.ts @@ -24,6 +24,11 @@ const dashboardStatusToasts: Record = { title: "Test wizard afgerond", message: "De generieke wizard-flow werkt nu vanaf het dashboard.", }, + "check-in-saved": { + variant: "success", + title: "Ochtendcheck-in opgeslagen", + message: "Je energiestart van vandaag staat nu klaar op je dashboard.", + }, }; const settingsStatusToasts: Record = { @@ -50,6 +55,14 @@ const onboardingErrorToasts: Record = { }, }; +const checkInErrorToasts: Record = { + "invalid-check-in-input": { + variant: "error", + title: "Check-in niet opgeslagen", + message: "Kies een energiescore tussen 1 en 10 en een geldige slaapkwaliteit.", + }, +}; + export function getDashboardStatusToast(status: string | null): StatusToast | null { if (!status) { return null; @@ -88,6 +101,21 @@ export function getOnboardingStatusToast( return null; } +export function getCheckInStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && checkInErrorToasts[error]) { + return checkInErrorToasts[error]; + } + + if (!status) { + return null; + } + + return null; +} + export function getAuthStatusToast( error: string | null, status: string | null, diff --git a/lib/forms/parse.ts b/lib/forms/parse.ts index 82faaa7..81c40d1 100644 --- a/lib/forms/parse.ts +++ b/lib/forms/parse.ts @@ -1,5 +1,6 @@ const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const INTEGER_VALUE_PATTERN = /^-?\d+$/; export class FormDataValidationError extends Error { code: string; @@ -115,3 +116,28 @@ export function assertMinLength( return value; } + +export function getIntegerValue( + formData: FormData, + key: string, + range: { min: number; max: number }, + errorCode: string, +): number { + const value = getRequiredString(formData, key, errorCode); + + if (!INTEGER_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + const parsedValue = Number.parseInt(value, 10); + + if ( + Number.isNaN(parsedValue) || + parsedValue < range.min || + parsedValue > range.max + ) { + fail(errorCode); + } + + return parsedValue; +} diff --git a/supabase/migrations/20260418_create_morning_check_ins.sql b/supabase/migrations/20260418_create_morning_check_ins.sql new file mode 100644 index 0000000..b35a2b1 --- /dev/null +++ b/supabase/migrations/20260418_create_morning_check_ins.sql @@ -0,0 +1,42 @@ +create table if not exists public.morning_check_ins ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + check_in_date date not null, + energy_score integer not null check (energy_score between 1 and 10), + sleep_quality text not null check (sleep_quality in ('goed', 'matig', 'slecht')), + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique (user_id, check_in_date) +); + +grant select, insert, update on table public.morning_check_ins to authenticated; + +alter table public.morning_check_ins enable row level security; + +drop trigger if exists set_morning_check_ins_updated_at on public.morning_check_ins; +create trigger set_morning_check_ins_updated_at +before update on public.morning_check_ins +for each row +execute function public.set_updated_at(); + +drop policy if exists "morning_check_ins_select_own" on public.morning_check_ins; +create policy "morning_check_ins_select_own" +on public.morning_check_ins +for select +to authenticated +using ((select auth.uid()) = user_id); + +drop policy if exists "morning_check_ins_insert_own" on public.morning_check_ins; +create policy "morning_check_ins_insert_own" +on public.morning_check_ins +for insert +to authenticated +with check ((select auth.uid()) = user_id); + +drop policy if exists "morning_check_ins_update_own" on public.morning_check_ins; +create policy "morning_check_ins_update_own" +on public.morning_check_ins +for update +to authenticated +using ((select auth.uid()) = user_id) +with check ((select auth.uid()) = user_id);