Add demo usage seeds and seed script polish

This commit is contained in:
Janpeter Visser 2026-04-19 15:38:31 +02:00
parent b8c52e7948
commit 1ea2fcb826
9 changed files with 498 additions and 9 deletions

View file

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

View file

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

View file

@ -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 ?? "")}
>
<SelectTrigger className="h-11 w-full rounded-[1.15rem] bg-background/80 px-4 text-sm">
<SelectValue placeholder="Kies een skip-reden" />
<SelectValue placeholder="Kies een skip-reden">
{selectedSkipReason?.labelNl}
</SelectValue>
</SelectTrigger>
<SelectContent>
{skipReasons.map((skipReason) => (

View file

@ -150,7 +150,9 @@ export function ActivityForm({
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
<SelectValue placeholder="Kies een categorie" />
<SelectValue placeholder="Kies een categorie">
{selectedCategory?.labelNl}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categories.map((category) => (

View file

@ -150,7 +150,9 @@ export function AdHocActivityForm({
onValueChange={(value) => setCategoryId(value ?? categories[0]?.id ?? "")}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
<SelectValue placeholder="Kies een categorie" />
<SelectValue placeholder="Kies een categorie">
{selectedCategory?.labelNl}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categories.map((category) => (

View file

@ -56,7 +56,6 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
action={formAction}
className="space-y-6"
aria-busy={isPending}
encType="multipart/form-data"
>
<input type="hidden" name="locale" value={locale} />
<PreferenceHiddenFields draft={draft} />

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View file

@ -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.",
);
}

View file

@ -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.",
},
],
},
],
},
};