diff --git a/.env.example b/.env.example
index b89112c..c92f349 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,5 @@
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
+SUPABASE_SECRET_KEY=sb_secret_your_local_admin_key_here
+DEMO_USER_PASSWORD=DemoPassword123!
NEXT_PUBLIC_ENABLE_TEST_WIZARD=false
diff --git a/README.md b/README.md
index 39875fc..c9a4ba9 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,8 @@ persona-set in [inspannings-monitor-08-gebruikerspersonas-v01.docx](/Users/janpe
Benodigd:
- `NEXT_PUBLIC_SUPABASE_URL`
-- `SUPABASE_SERVICE_ROLE_KEY`
+- `SUPABASE_SECRET_KEY` voorkeur
+- `SUPABASE_SERVICE_ROLE_KEY` mag nog als legacy alias
- `DEMO_USER_PASSWORD`
Uitvoeren:
@@ -110,6 +111,12 @@ Uitvoeren:
2. zet de drie env-vars lokaal
3. run `npm run seed:demo-users`
+Voor bestaande lokale setups accepteert het script tijdelijk ook:
+- `NEXT_PUBLIC_SUPABASE_SERVICE_KEY`
+
+Maar mijn advies is om voor seedscripts alleen deze nette niet-public adminnaam te gebruiken:
+- `SUPABASE_SECRET_KEY`
+
De seeddata zelf staat in:
- [demo-personas.mjs](/Users/janpetervisser/Development/third/scripts/seed/demo-personas.mjs)
diff --git a/components/planning/activity-evaluation-fields.tsx b/components/planning/activity-evaluation-fields.tsx
index 87dcde1..9e4ce08 100644
--- a/components/planning/activity-evaluation-fields.tsx
+++ b/components/planning/activity-evaluation-fields.tsx
@@ -33,6 +33,8 @@ export function ActivityEvaluationFields({
initialSkipReasonId ?? skipReasons[0]?.id ?? "",
);
const [notes, setNotes] = useState(initialNotes ?? "");
+ const selectedSkipReason =
+ skipReasons.find((skipReason) => skipReason.id === skipReasonId) ?? null;
if (status !== "skipped" && status !== "adjusted") {
return null;
@@ -54,7 +56,9 @@ export function ActivityEvaluationFields({
onValueChange={(value) => setSkipReasonId(value ?? skipReasons[0]?.id ?? "")}
>
-
+
+ {selectedSkipReason?.labelNl}
+
{skipReasons.map((skipReason) => (
diff --git a/components/planning/activity-form.tsx b/components/planning/activity-form.tsx
index ed70955..431183b 100644
--- a/components/planning/activity-form.tsx
+++ b/components/planning/activity-form.tsx
@@ -150,7 +150,9 @@ export function ActivityForm({
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
>
-
+
+ {selectedCategory?.labelNl}
+
{categories.map((category) => (
diff --git a/components/planning/ad-hoc-activity-form.tsx b/components/planning/ad-hoc-activity-form.tsx
index c928953..9338be4 100644
--- a/components/planning/ad-hoc-activity-form.tsx
+++ b/components/planning/ad-hoc-activity-form.tsx
@@ -150,7 +150,9 @@ export function AdHocActivityForm({
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
>
-
+
+ {selectedCategory?.labelNl}
+
{categories.map((category) => (
diff --git a/components/settings/settings-form.tsx b/components/settings/settings-form.tsx
index 44a1721..9387979 100644
--- a/components/settings/settings-form.tsx
+++ b/components/settings/settings-form.tsx
@@ -56,7 +56,6 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
action={formAction}
className="space-y-6"
aria-busy={isPending}
- encType="multipart/form-data"
>
diff --git a/next-env.d.ts b/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/scripts/seed-demo-users.mjs b/scripts/seed-demo-users.mjs
index f331340..d28e1ce 100644
--- a/scripts/seed-demo-users.mjs
+++ b/scripts/seed-demo-users.mjs
@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { createClient } from "@supabase/supabase-js";
import { demoPersonas } from "./seed/demo-personas.mjs";
+import { demoUsageSeeds } from "./seed/demo-usage-seeds.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
@@ -47,13 +48,22 @@ await loadEnvFile(path.join(rootDir, ".env.local"));
await loadEnvFile(path.join(rootDir, ".env"));
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
-const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+const serviceRoleKey =
+ process.env.SUPABASE_SECRET_KEY ??
+ process.env.SUPABASE_SERVICE_ROLE_KEY ??
+ process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY;
const demoUserPassword = process.env.DEMO_USER_PASSWORD;
const isDryRun = process.argv.includes("--dry-run");
if (!supabaseUrl || !serviceRoleKey) {
throw new Error(
- "Zet NEXT_PUBLIC_SUPABASE_URL en SUPABASE_SERVICE_ROLE_KEY in je omgeving voordat je demo-users seedt.",
+ "Zet NEXT_PUBLIC_SUPABASE_URL en een admin key (liefst SUPABASE_SECRET_KEY, anders SUPABASE_SERVICE_ROLE_KEY) in je omgeving voordat je demo-users seedt.",
+ );
+}
+
+if (!process.env.SUPABASE_SECRET_KEY && process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY) {
+ console.warn(
+ "Let op: het seedscript gebruikt nu NEXT_PUBLIC_SUPABASE_SERVICE_KEY als fallback. Gebruik liever SUPABASE_SECRET_KEY in .env.local voor lokale admin-taken.",
);
}
@@ -70,6 +80,73 @@ const supabase = createClient(supabaseUrl, serviceRoleKey, {
},
});
+const BUDGET_FORMULA_VERSION = 1;
+
+function deriveEnergyLevel(energyScore) {
+ if (energyScore <= 2) {
+ return "zeer_laag";
+ }
+
+ if (energyScore <= 4) {
+ return "laag";
+ }
+
+ if (energyScore <= 6) {
+ return "midden";
+ }
+
+ if (energyScore <= 8) {
+ return "redelijk";
+ }
+
+ return "hoog";
+}
+
+function deriveBudgetSnapshot(energyScore) {
+ return {
+ energy_level: deriveEnergyLevel(energyScore),
+ daily_budget: energyScore,
+ budget_formula_version: BUDGET_FORMULA_VERSION,
+ };
+}
+
+function formatDatePart(value) {
+ return String(value).padStart(2, "0");
+}
+
+function getDatePartsInTimezone(date, timezone) {
+ 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(`Kon lokale datum niet bepalen voor timezone ${timezone}.`);
+ }
+
+ return { year, month, day };
+}
+
+function getDateStringWithOffset(timezone, dayOffset) {
+ const now = new Date();
+ const { year, month, day } = getDatePartsInTimezone(now, timezone);
+ const baseDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
+ baseDate.setUTCDate(baseDate.getUTCDate() + dayOffset);
+
+ return [
+ baseDate.getUTCFullYear(),
+ formatDatePart(baseDate.getUTCMonth() + 1),
+ formatDatePart(baseDate.getUTCDate()),
+ ].join("-");
+}
+
function getAvatarPath(userId) {
return `${userId}/avatar`;
}
@@ -217,6 +294,136 @@ async function upsertSettings(userId, persona) {
}
}
+async function loadReferenceMap(tableName) {
+ const { data, error } = await supabase.from(tableName).select("id, key");
+
+ if (error) {
+ throw new Error(`Kon referentiedata uit ${tableName} niet laden: ${error.message}`);
+ }
+
+ return new Map(data.map((row) => [row.key, row.id]));
+}
+
+async function seedUsageData(userId, persona) {
+ const usageSeed = demoUsageSeeds[persona.email];
+
+ if (!usageSeed) {
+ return {
+ checkIns: 0,
+ activities: 0,
+ };
+ }
+
+ const categoryIdsByKey = await loadReferenceMap("activity_categories");
+ const skipReasonIdsByKey = await loadReferenceMap("skip_reasons");
+
+ const checkIns = usageSeed.checkIns.map((entry) => {
+ const checkInDate = getDateStringWithOffset(persona.timezone, entry.dayOffset);
+
+ return {
+ user_id: userId,
+ check_in_date: checkInDate,
+ energy_score: entry.energyScore,
+ sleep_quality: entry.sleepQuality,
+ ...deriveBudgetSnapshot(entry.energyScore),
+ };
+ });
+
+ const activityDateMap = new Map();
+
+ for (const dayGroup of usageSeed.activities) {
+ activityDateMap.set(
+ dayGroup.dayOffset,
+ getDateStringWithOffset(persona.timezone, dayGroup.dayOffset),
+ );
+ }
+
+ if (isDryRun) {
+ return {
+ checkIns: checkIns.length,
+ activities: usageSeed.activities.reduce(
+ (count, dayGroup) => count + dayGroup.items.length,
+ 0,
+ ),
+ };
+ }
+
+ if (checkIns.length > 0) {
+ const { error } = await supabase.from("morning_check_ins").upsert(checkIns, {
+ onConflict: "user_id,check_in_date",
+ });
+
+ if (error) {
+ throw new Error(`Kon check-ins voor ${persona.email} niet seeden: ${error.message}`);
+ }
+ }
+
+ const seededDates = [...new Set([...activityDateMap.values()])];
+
+ if (seededDates.length > 0) {
+ const { error: deleteError } = await supabase
+ .from("activities")
+ .delete()
+ .eq("user_id", userId)
+ .in("activity_date", seededDates);
+
+ if (deleteError) {
+ throw new Error(`Kon bestaande activiteiten voor ${persona.email} niet vervangen: ${deleteError.message}`);
+ }
+ }
+
+ const activityRows = usageSeed.activities.flatMap((dayGroup) => {
+ const activityDate = activityDateMap.get(dayGroup.dayOffset);
+
+ return dayGroup.items.map((item) => {
+ const categoryId = categoryIdsByKey.get(item.categoryKey);
+
+ if (!categoryId) {
+ throw new Error(
+ `Onbekende activity category key '${item.categoryKey}' voor ${persona.email}.`,
+ );
+ }
+
+ const skipReasonId = item.skipReasonKey
+ ? skipReasonIdsByKey.get(item.skipReasonKey)
+ : null;
+
+ if (item.skipReasonKey && !skipReasonId) {
+ throw new Error(
+ `Onbekende skip reason key '${item.skipReasonKey}' voor ${persona.email}.`,
+ );
+ }
+
+ return {
+ user_id: userId,
+ activity_date: activityDate,
+ source: item.source,
+ status: item.status,
+ name: item.name,
+ category_id: categoryId,
+ duration_minutes: item.durationMinutes,
+ impact_level: item.impactLevel,
+ priority_level: item.priorityLevel,
+ skip_reason_id: skipReasonId,
+ notes: item.notes ?? null,
+ };
+ });
+ });
+
+ if (activityRows.length > 0) {
+ const { error } = await supabase.from("activities").insert(activityRows);
+
+ if (error) {
+ throw new Error(`Kon activiteiten voor ${persona.email} niet seeden: ${error.message}`);
+ }
+ }
+
+ return {
+ checkIns: checkIns.length,
+ activities: activityRows.length,
+ };
+}
+
async function main() {
const existingUsersByEmail = await loadExistingUsersByEmail();
const results = [];
@@ -227,11 +434,14 @@ async function main() {
await upsertProfile(userId, persona, avatarPath);
await upsertSettings(userId, persona);
+ const usageResult = await seedUsageData(userId, persona);
results.push({
email: persona.email,
userId,
avatar: avatarPath ? "ja" : "nee",
+ checkIns: usageResult.checkIns,
+ activities: usageResult.activities,
dryRun: isDryRun ? "ja" : "nee",
});
}
@@ -240,7 +450,7 @@ async function main() {
console.log(
isDryRun
? "Dry run klaar. Er zijn geen wijzigingen weggeschreven."
- : "Demo-gebruikers, profielen, settings en avatars zijn gesynchroniseerd.",
+ : "Demo-gebruikers, profielen, settings, avatars en usage-seeds zijn gesynchroniseerd.",
);
}
diff --git a/scripts/seed/demo-usage-seeds.mjs b/scripts/seed/demo-usage-seeds.mjs
new file mode 100644
index 0000000..76ddab3
--- /dev/null
+++ b/scripts/seed/demo-usage-seeds.mjs
@@ -0,0 +1,263 @@
+export const demoUsageSeeds = {
+ "lisa.vermeulen+demo@example.com": {
+ summary:
+ "Lichte, fluctuerende week voor Lisa met nadruk op doseren, korte taken en herstelmomenten.",
+ checkIns: [
+ {
+ dayOffset: -4,
+ energyScore: 4,
+ sleepQuality: "matig",
+ },
+ {
+ dayOffset: -3,
+ energyScore: 2,
+ sleepQuality: "slecht",
+ },
+ {
+ dayOffset: -2,
+ energyScore: 5,
+ sleepQuality: "goed",
+ },
+ {
+ dayOffset: -1,
+ energyScore: 3,
+ sleepQuality: "matig",
+ },
+ {
+ dayOffset: 0,
+ energyScore: 2,
+ sleepQuality: "slecht",
+ },
+ ],
+ activities: [
+ {
+ dayOffset: -4,
+ items: [
+ {
+ source: "planned",
+ status: "completed",
+ name: "Douchen en rustig aankleden",
+ categoryKey: "huishouden",
+ durationMinutes: 20,
+ impactLevel: "midden",
+ priorityLevel: "hoog",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "completed",
+ name: "Korte wandeling door de straat",
+ categoryKey: "beweging",
+ durationMinutes: 15,
+ impactLevel: "laag",
+ priorityLevel: "normaal",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "adjusted",
+ name: "Mail naar studieadviseur sturen",
+ categoryKey: "administratie",
+ durationMinutes: 20,
+ impactLevel: "midden",
+ priorityLevel: "normaal",
+ notes: "In twee korte blokken gedaan met een pauze tussendoor.",
+ },
+ {
+ source: "ad_hoc",
+ status: "completed",
+ name: "Liggen in donkere kamer",
+ categoryKey: "rust_herstel",
+ durationMinutes: 45,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: "Extra rust genomen na de wandeling om hoofdpijn voor te blijven.",
+ },
+ ],
+ },
+ {
+ dayOffset: -3,
+ items: [
+ {
+ source: "planned",
+ status: "completed",
+ name: "Ontbijt maken",
+ categoryKey: "huishouden",
+ durationMinutes: 15,
+ impactLevel: "laag",
+ priorityLevel: "hoog",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "skipped",
+ name: "Videobellen met vriendin",
+ categoryKey: "sociaal",
+ durationMinutes: 20,
+ impactLevel: "midden",
+ priorityLevel: "laag",
+ skipReasonKey: "te_belastend",
+ notes: "Te veel last van licht en geluid, daarom verplaatst.",
+ },
+ {
+ source: "planned",
+ status: "adjusted",
+ name: "Lezen op laptop",
+ categoryKey: "vrije_tijd",
+ durationMinutes: 20,
+ impactLevel: "midden",
+ priorityLevel: "laag",
+ notes: "Na tien minuten gestopt door hoofdpijn en daarna audio geluisterd.",
+ },
+ {
+ source: "ad_hoc",
+ status: "completed",
+ name: "Middag slapen",
+ categoryKey: "rust_herstel",
+ durationMinutes: 60,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: "Herstel na een prikkelrijke ochtend.",
+ },
+ ],
+ },
+ {
+ dayOffset: -2,
+ items: [
+ {
+ source: "planned",
+ status: "completed",
+ name: "Kleine was draaien",
+ categoryKey: "huishouden",
+ durationMinutes: 25,
+ impactLevel: "midden",
+ priorityLevel: "normaal",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "completed",
+ name: "Korte wandeling naar het parkje",
+ categoryKey: "beweging",
+ durationMinutes: 20,
+ impactLevel: "laag",
+ priorityLevel: "normaal",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "completed",
+ name: "Bericht naar mentor van oude studie",
+ categoryKey: "administratie",
+ durationMinutes: 15,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: null,
+ },
+ {
+ source: "ad_hoc",
+ status: "completed",
+ name: "Luisteren naar rustige podcast",
+ categoryKey: "vrije_tijd",
+ durationMinutes: 30,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: "Lagere prikkelactiviteit dan gepland schermgebruik.",
+ },
+ ],
+ },
+ {
+ dayOffset: -1,
+ items: [
+ {
+ source: "planned",
+ status: "completed",
+ name: "Douchen en haren wassen",
+ categoryKey: "huishouden",
+ durationMinutes: 20,
+ impactLevel: "midden",
+ priorityLevel: "hoog",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "skipped",
+ name: "Boekje in de tuin lezen",
+ categoryKey: "vrije_tijd",
+ durationMinutes: 25,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ skipReasonKey: "energie_te_laag",
+ notes: "Na het douchen was de energie al op.",
+ },
+ {
+ source: "planned",
+ status: "adjusted",
+ name: "Kamer opruimen",
+ categoryKey: "huishouden",
+ durationMinutes: 25,
+ impactLevel: "midden",
+ priorityLevel: "normaal",
+ notes: "Alleen nachtkastje en stoel gedaan, rest doorgeschoven.",
+ },
+ {
+ source: "ad_hoc",
+ status: "completed",
+ name: "Extra rust onder deken",
+ categoryKey: "rust_herstel",
+ durationMinutes: 45,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: "Bewust meer herstel ingepland om crash te voorkomen.",
+ },
+ ],
+ },
+ {
+ dayOffset: 0,
+ items: [
+ {
+ source: "planned",
+ status: "completed",
+ name: "Ontbijt en medicatie",
+ categoryKey: "huishouden",
+ durationMinutes: 15,
+ impactLevel: "laag",
+ priorityLevel: "hoog",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "planned",
+ name: "Korte wandeling rond het blok",
+ categoryKey: "beweging",
+ durationMinutes: 10,
+ impactLevel: "laag",
+ priorityLevel: "normaal",
+ notes: null,
+ },
+ {
+ source: "planned",
+ status: "skipped",
+ name: "Tien minuten administratie",
+ categoryKey: "administratie",
+ durationMinutes: 15,
+ impactLevel: "midden",
+ priorityLevel: "laag",
+ skipReasonKey: "energie_te_laag",
+ notes: "Eerst rust nodig voordat schermwerk lukt.",
+ },
+ {
+ source: "ad_hoc",
+ status: "completed",
+ name: "Rustpauze in verduisterde kamer",
+ categoryKey: "rust_herstel",
+ durationMinutes: 60,
+ impactLevel: "laag",
+ priorityLevel: "laag",
+ notes: "Extra herstel toegevoegd nadat de ochtend zwaarder voelde dan verwacht.",
+ },
+ ],
+ },
+ ],
+ },
+};