Implement ST-201 morning check-in flow
This commit is contained in:
parent
000d2351c1
commit
f5b459dadb
12 changed files with 710 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
47
app/check-in/actions.ts
Normal file
47
app/check-in/actions.ts
Normal file
|
|
@ -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<null> {
|
||||
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;
|
||||
}
|
||||
135
app/check-in/page.tsx
Normal file
135
app/check-in/page.tsx
Normal file
|
|
@ -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<PageSearchParams>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>Ochtendcheck-in</span>
|
||||
</div>
|
||||
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
|
||||
Ochtendcheck-in van vandaag
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
|
||||
Houd je start rustig en klein. Je legt alleen een energiescore en een
|
||||
globale slaapindruk vast voor vandaag.
|
||||
</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]">
|
||||
<CheckInForm todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||
|
||||
<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">
|
||||
{checkInStatus?.todayCheckIn ? "Check-in staat al klaar" : "Nog geen check-in"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||
Lokale datum: {checkInStatus?.todayDate ?? "Onbekend"} in timezone{" "}
|
||||
`{profileBundle.profile.timezone}`.
|
||||
</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 check-in geeft geen diagnose of medische interpretatie.</p>
|
||||
<p>Je legt alleen een rustige momentopname van vandaag vast.</p>
|
||||
<p>De budgetlogica volgt pas in de volgende story.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CheckInCard todayCheckIn={checkInStatus?.todayCheckIn ?? null} />
|
||||
|
||||
{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">
|
||||
|
|
|
|||
56
components/check-in/check-in-card.tsx
Normal file
56
components/check-in/check-in-card.tsx
Normal file
|
|
@ -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 (
|
||||
<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">
|
||||
Ochtendcheck-in
|
||||
</p>
|
||||
<CardTitle className="text-lg text-slate-900">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pb-6">
|
||||
<CardDescription className="text-sm leading-7 text-muted-foreground">
|
||||
{description}
|
||||
</CardDescription>
|
||||
<Link
|
||||
href="/check-in"
|
||||
className={cn(buttonVariants({ size: "lg" }), "h-11 rounded-full px-5")}
|
||||
>
|
||||
{todayCheckIn ? "Werk check-in bij" : "Start check-in"}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
178
components/check-in/check-in-form.tsx
Normal file
178
components/check-in/check-in-form.tsx
Normal file
|
|
@ -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<number | null>(
|
||||
todayCheckIn?.energyScore ?? null,
|
||||
);
|
||||
const [sleepQuality, setSleepQuality] = useState<SleepQuality | null>(
|
||||
todayCheckIn?.sleepQuality ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-6" aria-busy={isPending}>
|
||||
<input type="hidden" name="energyScore" value={energyScore ?? ""} />
|
||||
<input type="hidden" name="sleepQuality" value={sleepQuality ?? ""} />
|
||||
|
||||
<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">
|
||||
Ochtendcheck-in
|
||||
</p>
|
||||
<CardTitle className="font-[family-name:var(--font-display)] text-3xl text-slate-900">
|
||||
Hoe start je vandaag?
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||
Houd deze check-in klein. Je legt alleen vast hoe je energie en slaap
|
||||
vandaag voelen, zodat de volgende stories daarop kunnen voortbouwen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-semibold text-slate-900">
|
||||
Energiescore vandaag
|
||||
</Label>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
{getEnergyScorePrompt(energyScore)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{ENERGY_SCORE_VALUES.map((value) => {
|
||||
const isSelected = energyScore === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setEnergyScore(value)}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isSelected ? "default" : "outline",
|
||||
size: "lg",
|
||||
}),
|
||||
"h-12 rounded-[1rem] px-0",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-semibold text-slate-900">
|
||||
Hoe voelde je slaap?
|
||||
</Label>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Eén globale indruk is genoeg voor deze eerste release.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{SLEEP_QUALITY_OPTIONS.map((option) => {
|
||||
const isSelected = sleepQuality === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setSleepQuality(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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isPending || energyScore === null || sleepQuality === null}
|
||||
className="h-11 rounded-full px-5"
|
||||
>
|
||||
{isPending
|
||||
? "Check-in opslaan..."
|
||||
: todayCheckIn
|
||||
? "Werk check-in bij"
|
||||
: "Sla check-in op"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
21
lib/check-in/options.ts
Normal file
21
lib/check-in/options.ts
Normal file
|
|
@ -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;
|
||||
146
lib/check-in/service.ts
Normal file
146
lib/check-in/service.ts
Normal file
|
|
@ -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<ReturnType<typeof createClient>>;
|
||||
|
||||
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<MorningCheckInRow | null> {
|
||||
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<MorningCheckInStatus | null> {
|
||||
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<MorningCheckInRecord> {
|
||||
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);
|
||||
}
|
||||
22
lib/check-in/types.ts
Normal file
22
lib/check-in/types.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -24,6 +24,11 @@ const dashboardStatusToasts: Record<string, StatusToast> = {
|
|||
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<string, StatusToast> = {
|
||||
|
|
@ -50,6 +55,14 @@ const onboardingErrorToasts: Record<string, StatusToast> = {
|
|||
},
|
||||
};
|
||||
|
||||
const checkInErrorToasts: Record<string, StatusToast> = {
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
42
supabase/migrations/20260418_create_morning_check_ins.sql
Normal file
42
supabase/migrations/20260418_create_morning_check_ins.sql
Normal file
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue