From 1ea2fcb8265651f5144e2d89bffbb2217eca9a64 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 15:38:31 +0200 Subject: [PATCH] Add demo usage seeds and seed script polish --- .env.example | 2 + README.md | 9 +- .../planning/activity-evaluation-fields.tsx | 6 +- components/planning/activity-form.tsx | 4 +- components/planning/ad-hoc-activity-form.tsx | 4 +- components/settings/settings-form.tsx | 1 - next-env.d.ts | 2 +- scripts/seed-demo-users.mjs | 216 +++++++++++++- scripts/seed/demo-usage-seeds.mjs | 263 ++++++++++++++++++ 9 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 scripts/seed/demo-usage-seeds.mjs 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.", + }, + ], + }, + ], + }, +};