Implement ST-301 planning data model
This commit is contained in:
parent
8864eb7966
commit
44bd946290
7 changed files with 439 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
29
lib/planning/options.ts
Normal file
29
lib/planning/options.ts
Normal file
|
|
@ -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;
|
||||
211
lib/planning/service.ts
Normal file
211
lib/planning/service.ts
Normal file
|
|
@ -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<ReturnType<typeof createClient>>;
|
||||
|
||||
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<ActivityRecord[]> {
|
||||
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<ActivityCategory[]> {
|
||||
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<SkipReason[]> {
|
||||
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<ActivityRecord[] | null> {
|
||||
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<ActivitiesForDateStatus | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
52
lib/planning/types.ts
Normal file
52
lib/planning/types.ts
Normal file
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue