Implement ST-201 morning check-in flow

This commit is contained in:
Janpeter Visser 2026-04-18 18:42:31 +02:00
parent 000d2351c1
commit f5b459dadb
12 changed files with 710 additions and 3 deletions

21
lib/check-in/options.ts Normal file
View 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
View 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
View 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;
};

View file

@ -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,

View file

@ -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;
}