diff --git a/README.md b/README.md index f67d0c2..09a7504 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag - eenvoudig dagbudget en energieniveau op basis van de ochtendscore - dashboardweergave van check-instatus, energieniveau en dagbudget +- planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase - eerste unit tests voor budgetmapping via `Vitest` - korte onboardingflow voor eerste voorkeuren - instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten @@ -77,6 +78,7 @@ De huidige app gebruikt onder meer deze migraties: - `supabase/migrations/20260418_add_onboarding_seen_to_profiles.sql` - `supabase/migrations/20260418_create_morning_check_ins.sql` - `supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql` +- `supabase/migrations/20260419_create_activities_and_reference_data.sql` Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je de profile-, check-in- en budgetlagen lokaal test. @@ -109,7 +111,7 @@ zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## Eerstvolgende bouwstappen -1. `ST-301` Activiteitenmodel en planning opzetten +1. `ST-302` Planningformulier bouwen 2. `ST-304` EnergyMeter en lopend totaal implementeren 3. `ST-401` Evaluatie- en dagoverzichtslus bouwen 4. `ST-105` RLS-policy tests en hardening afronden diff --git a/docs/README.md b/docs/README.md index 89bc97d..3909ce2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,7 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - Ochtendcheck-in slaat nu energiescore en slaapkwaliteit per dag op - Dagbudget v1 is bewust eenvoudig: `daily_budget = energy_score` - Energieniveau en budget worden al direct getoond in check-in en dashboard +- `ST-301` legt nu ook het activiteitenmodel, categorieën en skip-redenen vast - Eerste unit tests voor budgetmapping draaien via `Vitest` ## Generator diff --git a/docs/backlog/inspannings-monitor-backlog.md b/docs/backlog/inspannings-monitor-backlog.md index d7488fc..57e63c0 100644 --- a/docs/backlog/inspannings-monitor-backlog.md +++ b/docs/backlog/inspannings-monitor-backlog.md @@ -80,7 +80,7 @@ Doel: de gebruiker kan activiteiten voor de dag plannen binnen een eenvoudig ene | Story ID | Titel | Type | Definition of done | | --- | --- | --- | --- | -| ST-301 | Datamodel voor activiteiten implementeren | Build | Migraties en seed-data voor categorieën en skip-redenen zijn aanwezig | +| ST-301 | Datamodel voor activiteiten implementeren | Build | Afgerond: migraties en seed-data voor categorieën en skip-redenen zijn aanwezig | | ST-302 | Planningformulier bouwen | UI | Activiteit kan met naam, categorie, duur, impact en prioriteit worden aangemaakt | | ST-303 | Autocomplete op eerdere activiteiten toevoegen | UX | Veelgebruikte activiteiten zijn snel opnieuw te kiezen | | ST-304 | EnergyMeter en lopend totaal implementeren | Logic/UI | Totaal update direct na elke wijziging | diff --git a/lib/planning/options.ts b/lib/planning/options.ts new file mode 100644 index 0000000..20ddb2a --- /dev/null +++ b/lib/planning/options.ts @@ -0,0 +1,29 @@ +export const ACTIVITY_SOURCE_VALUES = ["planned", "ad_hoc"] as const; +export const ACTIVITY_STATUS_VALUES = [ + "planned", + "completed", + "skipped", + "adjusted", +] as const; +export const ACTIVITY_IMPACT_LEVEL_VALUES = ["laag", "midden", "hoog"] as const; +export const ACTIVITY_PRIORITY_LEVEL_VALUES = ["laag", "normaal", "hoog"] as const; + +export const SEEDED_ACTIVITY_CATEGORY_KEYS = [ + "huishouden", + "werk_studie", + "administratie", + "sociaal", + "beweging", + "rust_herstel", + "reizen", + "vrije_tijd", +] as const; + +export const SEEDED_SKIP_REASON_KEYS = [ + "energie_te_laag", + "prioriteit_veranderd", + "praktische_belemmering", + "duurde_langer_dan_verwacht", + "te_belastend", + "vergeten", +] as const; diff --git a/lib/planning/service.ts b/lib/planning/service.ts new file mode 100644 index 0000000..5825c59 --- /dev/null +++ b/lib/planning/service.ts @@ -0,0 +1,211 @@ +import { getAuthenticatedUser } from "@/lib/auth/session"; +import type { + ActivityCategory, + ActivityImpactLevel, + ActivityPriorityLevel, + ActivityRecord, + ActivitySource, + ActivitiesForDateStatus, + ActivityStatus, + SkipReason, +} from "@/lib/planning/types"; +import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { createClient } from "@/lib/supabase/server"; + +type SupabaseServerClient = Awaited>; + +type ActivityCategoryRow = { + id: string; + key: string; + label_nl: string; + sort_order: number; + is_active: boolean; + created_at: string; +}; + +type SkipReasonRow = { + id: string; + key: string; + label_nl: string; + sort_order: number; + is_active: boolean; + created_at: string; +}; + +type ActivityRow = { + id: string; + user_id: string; + activity_date: string; + source: ActivitySource; + status: ActivityStatus; + name: string; + category_id: string; + duration_minutes: number; + impact_level: ActivityImpactLevel; + priority_level: ActivityPriorityLevel; + skip_reason_id: string | null; + notes: string | null; + created_at: string; + updated_at: string; +}; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ACTIVITY_CATEGORY_COLUMNS = + "id, key, label_nl, sort_order, is_active, created_at"; +const SKIP_REASON_COLUMNS = + "id, key, label_nl, sort_order, is_active, created_at"; +const ACTIVITY_COLUMNS = + "id, user_id, activity_date, source, status, name, category_id, duration_minutes, impact_level, priority_level, skip_reason_id, notes, created_at, updated_at"; + +function mapActivityCategoryRow(row: ActivityCategoryRow): ActivityCategory { + return { + id: row.id, + key: row.key, + labelNl: row.label_nl, + sortOrder: row.sort_order, + isActive: row.is_active, + createdAt: row.created_at, + }; +} + +function mapSkipReasonRow(row: SkipReasonRow): SkipReason { + return { + id: row.id, + key: row.key, + labelNl: row.label_nl, + sortOrder: row.sort_order, + isActive: row.is_active, + createdAt: row.created_at, + }; +} + +function mapActivityRow(row: ActivityRow): ActivityRecord { + return { + id: row.id, + userId: row.user_id, + activityDate: row.activity_date, + source: row.source, + status: row.status, + name: row.name, + categoryId: row.category_id, + durationMinutes: row.duration_minutes, + impactLevel: row.impact_level, + priorityLevel: row.priority_level, + skipReasonId: row.skip_reason_id, + notes: row.notes, + 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 plandatum voor timezone kon niet worden bepaald."); + } + + return `${year}-${month}-${day}`; +} + +function assertIsoDate(value: string) { + if (!ISO_DATE_PATTERN.test(value)) { + throw new Error("Ongeldige plandatum. Gebruik het formaat YYYY-MM-DD."); + } +} + +async function readActivitiesByDate( + supabase: SupabaseServerClient, + userId: string, + activityDate: string, +): Promise { + const { data, error } = await supabase + .from("activities") + .select(ACTIVITY_COLUMNS) + .eq("user_id", userId) + .eq("activity_date", activityDate) + .order("created_at", { ascending: true }); + + if (error) { + throw new Error(`Activiteiten konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapActivityRow); +} + +export async function listActivityCategories(): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from("activity_categories") + .select(ACTIVITY_CATEGORY_COLUMNS) + .order("sort_order", { ascending: true }); + + if (error) { + throw new Error(`Activiteitcategorieën konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapActivityCategoryRow); +} + +export async function listSkipReasons(): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from("skip_reasons") + .select(SKIP_REASON_COLUMNS) + .order("sort_order", { ascending: true }); + + if (error) { + throw new Error(`Skip-redenen konden niet worden geladen: ${error.message}`); + } + + return (data ?? []).map(mapSkipReasonRow); +} + +export async function getActivitiesForDateForCurrentUser( + activityDate: string, +): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + assertIsoDate(activityDate); + + const supabase = await createClient(); + return readActivitiesByDate(supabase, user.id, activityDate); +} + +export async function getTodayActivitiesForCurrentUser(): Promise { + const user = await getAuthenticatedUser(); + + if (!user) { + return null; + } + + const profileBundle = await ensureProfileBundleForCurrentUser(); + + if (!profileBundle) { + return null; + } + + const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone); + const supabase = await createClient(); + const activities = await readActivitiesByDate(supabase, user.id, activityDate); + + return { + timezone: profileBundle.profile.timezone, + activityDate, + activities, + }; +} diff --git a/lib/planning/types.ts b/lib/planning/types.ts new file mode 100644 index 0000000..52bc4fc --- /dev/null +++ b/lib/planning/types.ts @@ -0,0 +1,52 @@ +import { + ACTIVITY_IMPACT_LEVEL_VALUES, + ACTIVITY_PRIORITY_LEVEL_VALUES, + ACTIVITY_SOURCE_VALUES, + ACTIVITY_STATUS_VALUES, +} from "@/lib/planning/options"; + +export type ActivitySource = (typeof ACTIVITY_SOURCE_VALUES)[number]; +export type ActivityStatus = (typeof ACTIVITY_STATUS_VALUES)[number]; +export type ActivityImpactLevel = (typeof ACTIVITY_IMPACT_LEVEL_VALUES)[number]; +export type ActivityPriorityLevel = (typeof ACTIVITY_PRIORITY_LEVEL_VALUES)[number]; + +export type ActivityCategory = { + id: string; + key: string; + labelNl: string; + sortOrder: number; + isActive: boolean; + createdAt: string; +}; + +export type SkipReason = { + id: string; + key: string; + labelNl: string; + sortOrder: number; + isActive: boolean; + createdAt: string; +}; + +export type ActivityRecord = { + id: string; + userId: string; + activityDate: string; + source: ActivitySource; + status: ActivityStatus; + name: string; + categoryId: string; + durationMinutes: number; + impactLevel: ActivityImpactLevel; + priorityLevel: ActivityPriorityLevel; + skipReasonId: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ActivitiesForDateStatus = { + timezone: string; + activityDate: string; + activities: ActivityRecord[]; +}; diff --git a/supabase/migrations/20260419_create_activities_and_reference_data.sql b/supabase/migrations/20260419_create_activities_and_reference_data.sql new file mode 100644 index 0000000..6687f05 --- /dev/null +++ b/supabase/migrations/20260419_create_activities_and_reference_data.sql @@ -0,0 +1,142 @@ +create table if not exists public.activity_categories ( + id uuid primary key, + key text not null unique, + label_nl text not null, + sort_order integer not null check (sort_order >= 1), + is_active boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.skip_reasons ( + id uuid primary key, + key text not null unique, + label_nl text not null, + sort_order integer not null check (sort_order >= 1), + is_active boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.activities ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + activity_date date not null, + source text not null default 'planned', + status text not null default 'planned', + name text not null, + category_id uuid not null references public.activity_categories (id), + duration_minutes integer not null, + impact_level text not null, + priority_level text not null, + skip_reason_id uuid references public.skip_reasons (id), + notes text, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + constraint activities_source_check + check (source in ('planned', 'ad_hoc')), + constraint activities_status_check + check (status in ('planned', 'completed', 'skipped', 'adjusted')), + constraint activities_name_check + check (char_length(trim(name)) between 1 and 120), + constraint activities_duration_minutes_check + check (duration_minutes > 0 and duration_minutes <= 720), + constraint activities_impact_level_check + check (impact_level in ('laag', 'midden', 'hoog')), + constraint activities_priority_level_check + check (priority_level in ('laag', 'normaal', 'hoog')) +); + +create index if not exists activities_user_date_idx + on public.activities (user_id, activity_date); + +create index if not exists activity_categories_sort_order_idx + on public.activity_categories (sort_order); + +create index if not exists skip_reasons_sort_order_idx + on public.skip_reasons (sort_order); + +grant select on table public.activity_categories to authenticated; +grant select on table public.skip_reasons to authenticated; +grant select, insert, update, delete on table public.activities to authenticated; + +alter table public.activity_categories enable row level security; +alter table public.skip_reasons enable row level security; +alter table public.activities enable row level security; + +drop trigger if exists set_activities_updated_at on public.activities; +create trigger set_activities_updated_at +before update on public.activities +for each row +execute function public.set_updated_at(); + +drop policy if exists "activity_categories_select_active" on public.activity_categories; +create policy "activity_categories_select_active" +on public.activity_categories +for select +to authenticated +using (is_active = true); + +drop policy if exists "skip_reasons_select_active" on public.skip_reasons; +create policy "skip_reasons_select_active" +on public.skip_reasons +for select +to authenticated +using (is_active = true); + +drop policy if exists "activities_select_own" on public.activities; +create policy "activities_select_own" +on public.activities +for select +to authenticated +using ((select auth.uid()) = user_id); + +drop policy if exists "activities_insert_own" on public.activities; +create policy "activities_insert_own" +on public.activities +for insert +to authenticated +with check ((select auth.uid()) = user_id); + +drop policy if exists "activities_update_own" on public.activities; +create policy "activities_update_own" +on public.activities +for update +to authenticated +using ((select auth.uid()) = user_id) +with check ((select auth.uid()) = user_id); + +drop policy if exists "activities_delete_own" on public.activities; +create policy "activities_delete_own" +on public.activities +for delete +to authenticated +using ((select auth.uid()) = user_id); + +insert into public.activity_categories (id, key, label_nl, sort_order, is_active) +values + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1001', 'huishouden', 'Huishouden', 1, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1002', 'werk_studie', 'Werk of studie', 2, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1003', 'administratie', 'Administratie', 3, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1004', 'sociaal', 'Sociaal', 4, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1005', 'beweging', 'Beweging', 5, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1006', 'rust_herstel', 'Rust en herstel', 6, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1007', 'reizen', 'Reizen', 7, true), + ('0d0d8b31-5e4c-4d1d-b5df-6b98df0a1008', 'vrije_tijd', 'Vrije tijd', 8, true) +on conflict (key) do update +set + label_nl = excluded.label_nl, + sort_order = excluded.sort_order, + is_active = excluded.is_active; + +insert into public.skip_reasons (id, key, label_nl, sort_order, is_active) +values + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142001', 'energie_te_laag', 'Energie te laag', 1, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142002', 'prioriteit_veranderd', 'Prioriteit veranderde', 2, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142003', 'praktische_belemmering', 'Praktische belemmering', 3, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142004', 'duurde_langer_dan_verwacht', 'Vorige activiteit duurde langer', 4, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142005', 'te_belastend', 'Te belastend', 5, true), + ('9f4f1b75-f2a4-4d20-b80c-6f89e8142006', 'vergeten', 'Vergeten', 6, true) +on conflict (key) do update +set + label_nl = excluded.label_nl, + sort_order = excluded.sort_order, + is_active = excluded.is_active;