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
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue