My Prevention Plan source snapshot. Generated from an explicit allowlist; environment files and runtime data are excluded. ===== messages/en.json ===== { "skip": "Skip to main content", "workspace": "DEMO WORKSPACE", "roles": "Demo roles", "member": "Member", "coach": "Coach", "analyst": "Analyst", "memberSub": "Your plan and choices", "coachSub": "Support and proposals", "analystSub": "Rules and decisions", "synthetic": "Synthetic data", "fixedWeek": "Demo week: 5-11 October 2026", "backOverview": "Back to overview", "demo": "Guided demo", "loading": "Loading your workspace…", "reset": "Start again", "caseStudy": "MY PREVENTION PLAN", "memberTitle": "Make room for a small step.", "coachTitle": "Support starts with listening.", "analystTitle": "Every decision has a reason.", "subtitle": "A fictional service journey, with choice at every step.", "scenario": "Explore a scenario", "scenario_guided": "New guided journey", "scenario_camille": "Camille: difficulty", "scenario_jules": "Jules: incomplete data", "scenario_noor": "Noor: recorded progress", "nextStep": "YOUR NEXT STEP", "journey": "Guided journey", "preferencesGuide": "Save your preferences, then ask for a demo plan. Walking and 10-minute slots are the supported example.", "acceptGuide": "Review the proposed days. Accept to activate your plan, or decline and choose again.", "recordGuide": "Record a walk, save a check-in and calculate your weekly review. These are three separate actions.", "supportGuide": "Review the explanation. If you want help, enable coach sharing and explicitly request support.", "handoffGuide": "Your support request is saved. Switch to Coach to inspect the shared context and propose an adjustment.", "decisionGuide": "The coach proposal has not changed your plan. Accept or decline it; then inspect the Analyst view.", "coachGuide": "Inspect the explicit request, explain a smaller schedule and propose it. Only the member can activate the change.", "analystGuide": "Inspect rule inputs, outcomes and template versions. Personal notes are excluded from this view.", "stepPreferences": "Preferences", "stepPlan": "Agree a plan", "stepRecord": "Record", "stepReview": "Review", "stepCoach": "Coach", "stepDecision": "Decide", "preferencesTitle": "What fits your week?", "goal": "Your practical goal", "activity": "Preferred activity", "walking": "Walking", "cycling": "Cycling (unsupported example)", "minutesSlot": "Minutes per slot", "days": "Available days", "day1": "Monday", "day2": "Tuesday", "day3": "Wednesday", "day4": "Thursday", "day5": "Friday", "day6": "Saturday", "day7": "Sunday", "savePreferences": "Save preferences", "propose": "Propose my plan", "templateLimit": "This fictional template supports member-selected 10-minute walking slots. It is not a health recommendation; other preferences produce an explicit unsupported result.", "yourPlan": "Your walking plan", "version": "Version", "proposed": "Proposed", "active": "Active", "declined": "Declined", "matched": "A demo proposal based on the walking days and short slots you chose.", "minutes": "min", "templateDetails": "Template and evidence", "demoOnly": "Demo template, no clinical validation", "acceptPlan": "Accept this plan", "decline": "Decline", "journal": "Your activity journal", "date": "Activity date", "duration": "Duration in minutes", "addActivity": "Record a walk", "manual": "Manual record", "noRecords": "No activities recorded. This does not mean no activity happened.", "checkInTitle": "How did the plan fit your week?", "difficulty": "Member check-in", "difficult": "It was difficult", "manageable": "It was manageable", "not_attempted": "I did not attempt it", "prefer_not_to_say": "I prefer not to say", "reportingComplete": "I have finished reporting this week's activities", "optionalNote": "A note for the coach (optional)", "notePrivacy": "Only your assigned coach can read this note when you enable sharing. The analyst cannot read it.", "saveCheckIn": "Save check-in", "calculateReview": "Calculate weekly review", "unknownActivity": "Activity quantity is unknown; missing records are not zero activity.", "recordedSessions": "{count} recorded sessions / {target} agreed sessions", "completeData": "Reporting complete", "incompleteData": "Reporting incomplete. This does not mean you did not move.", "reviewDoesNotRequest": "A review never creates a support request automatically.", "memberChoice": "YOUR CHOICE", "coachProposal": "A proposal from your coach", "proposalSchedule": "Proposed walking days: {days}, 10 minutes per slot.", "unchanged": "Your active plan is still version {version} until you accept.", "acceptChange": "Accept adjustment", "permissions": "OPTIONAL SHARING", "choiceTitle": "You choose what to share.", "shareCoach": "Share my programme context with the assigned coach", "shareExplanation": "Share your goal, plan, activities, review and optional note. Turning this off immediately blocks coach access and cancels unresolved support and proposals.", "importNotImplemented": "Activity importing is not implemented in R1. Jules uses seeded incomplete coverage.", "fixturePermission": "New journeys start with optional permissions off. Ready-made Camille and Noor scenarios include previously granted fictional coach sharing.", "supportTitle": "A little support?", "supportExplanation": "Ask the assigned coach to review your plan. No response deadline is promised.", "requestSupport": "Request coach support", "enableSharing": "Enable coach sharing before requesting support.", "reviewFirst": "Calculate a weekly review before requesting support.", "goCoach": "Continue as Coach", "goMember": "Return to Member", "goAnalyst": "Inspect as Analyst", "history": "Accepted plan history", "slots": "walking slots", "status_requested": "Support requested", "status_proposed": "Proposal awaiting your choice", "status_closed": "Support closed", "status_cancelled": "Support cancelled after revocation", "assignedToYou": "ASSIGNED TO COACH ALEX", "sharedContext": "Shared programme context for this fictional member.", "waitingAcceptance": "Proposal saved. The current plan is unchanged while the member decides.", "proposeAdjustment": "Propose a smaller schedule", "coachExample": "Example adjustment: keep the first two current walking days at 10 minutes each. Explain why this could fit better.", "rationale": "Reason for the proposal", "sendProposal": "Send proposal for member review", "emptyQueue": "No unresolved assigned support requests. The member must explicitly request help first.", "memberDecides": "The member decides.", "revocationExplanation": "Sharing permission is checked again on every protected read and write.", "decisionHistory": "Pseudonymous decision history", "analystPrivacy": "Rule categories and versions only. No names, contact information or personal notes.", "noDecisions": "No reviews calculated yet. Return to Member and calculate a review.", "reviewRevision": "Review revision", "notReported": "Not reported", "dataQuality": "Data quality", "saved": "Saved. Your workspace shows the committed result.", "requestSaved": "Support request saved. Continue as Coach to review it.", "proposalSaved": "Proposal saved. Return to Member to accept or decline; the active plan has not changed.", "reload": "Reload current state", "footer": "Fictional case and synthetic data. No real notifications, wearable integration or clinical advice. Independent portfolio demonstration.", "outcomes": { "support_offered": "Support is available if you want it", "data_check_needed": "Check the missing data", "check_in_invited": "An optional check-in is available", "plan_review_offered": "You can review the plan", "progress_acknowledged": "Recorded progress acknowledged", "continue_or_adjust": "Continue or choose an adjustment" }, "errors": { "service_unavailable": "The service is unavailable. Your save is not confirmed. Check local PostgreSQL and reload.", "network_error": "The connection failed. Your save is not confirmed. Retry the same action without changing its fields.", "unauthenticated": "Your demo session has expired. Reload to start a new isolated journey.", "origin_forbidden": "The request origin is not allowed.", "invalid_json": "The request could not be read.", "payload_too_large": "The request is too large.", "stale_generation": "This journey was reset in another tab. Current state has been reloaded; review it before trying again.", "stale_actor": "Your role changed in another tab. Current state has been reloaded; review it before trying again.", "idempotency_conflict": "This retry key was used for a different action. Reload and try again.", "missing_idempotency_key": "A valid retry key is required.", "permission_revoked": "Coach sharing is off. Return to Member to inspect your permission choices.", "role_forbidden": "This action is not available to the current role.", "resource_not_found": "This resource is unavailable to the current actor.", "invalid_preferences": "Enter a goal, unique available days and a positive whole-number duration.", "no_suitable_template": "No demo template matches these preferences. Try walking with 10-minute slots; no new prescription has been generated.", "invalid_slots": "Select unique walking days with 10-minute slots.", "invalid_date": "Use a valid activity date on or before 11 October 2026; reviews use the week starting 5 October.", "invalid_duration": "Enter a whole-number duration between 1 and 1440 minutes.", "invalid_activity": "This demo records walking activities only.", "invalid_check_in": "Check the check-in selection and optional note length.", "invalid_consent": "Choose whether to grant sharing.", "invalid_request": "The request requires a current review from your active plan.", "invalid_transition": "This action does not fit the current state. Reload and review the next step.", "duplicate_request": "There is already one unresolved support request for this plan.", "stale_version": "The proposal refers to an older plan version. Reload before continuing.", "etag_mismatch": "The plan has changed. Current state has been reloaded; review it before accepting.", "precondition_required": "Reload to obtain the current plan version before continuing.", "invalid_rationale": "Add a reason of up to 500 characters.", "invalid_decision": "Choose accept or decline.", "invalid_role": "Choose Member, Coach or Analyst.", "invalid_scenario": "Choose one of the available demo scenarios." }, "finishReporting": "Finish reporting without a check-in", "completedGuide": "The adjustment is accepted. Inspect the Analyst view to trace the earlier review; its snapshot and your original plan version remain preserved." } ===== messages/nl-BE.json ===== { "skip": "Ga naar de hoofdinhoud", "workspace": "DEMO-WERKRUIMTE", "roles": "Demorollen", "member": "Deelnemer", "coach": "Coach", "analyst": "Analist", "memberSub": "Je plan en je keuzes", "coachSub": "Ondersteuning en voorstellen", "analystSub": "Regels en beslissingen", "synthetic": "Fictieve gegevens", "fixedWeek": "Demoweek: 5-11 oktober 2026", "backOverview": "Terug naar overzicht", "demo": "Begeleide demo", "loading": "Je werkruimte wordt geladen…", "reset": "Opnieuw beginnen", "caseStudy": "MY PREVENTION PLAN", "memberTitle": "Maak ruimte voor een kleine stap.", "coachTitle": "Ondersteuning begint met luisteren.", "analystTitle": "Elke beslissing heeft een reden.", "subtitle": "Een fictief traject, met keuzevrijheid bij elke stap.", "scenario": "Verken een scenario", "scenario_guided": "Nieuw begeleid traject", "scenario_camille": "Camille: moeilijkheden", "scenario_jules": "Jules: onvolledige gegevens", "scenario_noor": "Noor: geregistreerde vooruitgang", "nextStep": "JE VOLGENDE STAP", "journey": "Begeleid traject", "preferencesGuide": "Bewaar je voorkeuren en vraag daarna een demoplan. Wandelen in blokken van 10 minuten is het ondersteunde voorbeeld.", "acceptGuide": "Bekijk de voorgestelde dagen. Aanvaard om je plan te activeren, of wijs het af en kies opnieuw.", "recordGuide": "Registreer een wandeling, bewaar een check-in en bereken je weekoverzicht. Dit zijn drie afzonderlijke acties.", "supportGuide": "Lees de uitleg. Wil je hulp? Schakel delen met de coach in en vraag zelf ondersteuning.", "handoffGuide": "Je hulpvraag is bewaard. Ga naar Coach om de gedeelde context te bekijken en een aanpassing voor te stellen.", "decisionGuide": "Het coachvoorstel heeft je plan nog niet gewijzigd. Aanvaard of weiger het en bekijk daarna de Analist-weergave.", "coachGuide": "Bekijk de expliciete hulpvraag, leg een kleiner schema uit en stel het voor. Alleen de deelnemer kan de wijziging activeren.", "analystGuide": "Bekijk de invoercategorieën, resultaten en modelversies. Persoonlijke notities staan niet in deze weergave.", "stepPreferences": "Voorkeuren", "stepPlan": "Plan afspreken", "stepRecord": "Registreren", "stepReview": "Evalueren", "stepCoach": "Coach", "stepDecision": "Beslissen", "preferencesTitle": "Wat past in je week?", "goal": "Je praktische doel", "activity": "Voorkeursactiviteit", "walking": "Wandelen", "cycling": "Fietsen (niet ondersteund voorbeeld)", "minutesSlot": "Minuten per tijdsblok", "days": "Beschikbare dagen", "day1": "Maandag", "day2": "Dinsdag", "day3": "Woensdag", "day4": "Donderdag", "day5": "Vrijdag", "day6": "Zaterdag", "day7": "Zondag", "savePreferences": "Voorkeuren bewaren", "propose": "Mijn plan voorstellen", "templateLimit": "Dit fictieve model ondersteunt zelfgekozen wandelblokken van 10 minuten. Het is geen gezondheidsadvies. Andere voorkeuren geven een duidelijke melding dat er geen passend model is.", "yourPlan": "Je wandelplan", "version": "Versie", "proposed": "Voorgesteld", "active": "Actief", "declined": "Afgewezen", "matched": "Een demovoorstel op basis van je gekozen wandeldagen en korte tijdsblokken.", "minutes": "min", "templateDetails": "Model en onderbouwing", "demoOnly": "Demomodel, zonder klinische validatie", "acceptPlan": "Dit plan aanvaarden", "decline": "Afwijzen", "journal": "Je activiteitenlogboek", "date": "Datum van de activiteit", "duration": "Duur in minuten", "addActivity": "Wandeling registreren", "manual": "Handmatige registratie", "noRecords": "Geen activiteiten geregistreerd. Dat betekent niet dat er geen activiteit was.", "checkInTitle": "Hoe paste het plan in je week?", "difficulty": "Check-in van de deelnemer", "difficult": "Het was moeilijk", "manageable": "Het was haalbaar", "not_attempted": "Ik heb het niet geprobeerd", "prefer_not_to_say": "Ik zeg het liever niet", "reportingComplete": "Ik ben klaar met het registreren van de activiteiten van deze week", "optionalNote": "Een notitie voor de coach (optioneel)", "notePrivacy": "Alleen je toegewezen coach kan deze notitie lezen als je delen inschakelt. De analist kan ze niet lezen.", "saveCheckIn": "Check-in bewaren", "calculateReview": "Weekoverzicht berekenen", "unknownActivity": "De hoeveelheid activiteit is onbekend. Ontbrekende registraties betekenen niet nul activiteit.", "recordedSessions": "{count} geregistreerde sessies / {target} afgesproken sessies", "completeData": "Registratie volledig", "incompleteData": "Registratie onvolledig. Dat betekent niet dat je niet bewogen hebt.", "reviewDoesNotRequest": "Een weekoverzicht maakt nooit automatisch een hulpvraag aan.", "memberChoice": "JOUW KEUZE", "coachProposal": "Een voorstel van je coach", "proposalSchedule": "Voorgestelde wandeldagen: {days}, 10 minuten per tijdsblok.", "unchanged": "Je actieve plan blijft versie {version} tot je het voorstel aanvaardt.", "acceptChange": "Aanpassing aanvaarden", "permissions": "OPTIONEEL DELEN", "choiceTitle": "Jij kiest wat je deelt.", "shareCoach": "Mijn programmacontext delen met de toegewezen coach", "shareExplanation": "Deel je doel, plan, activiteiten, weekoverzicht en optionele notitie. Uitschakelen blokkeert meteen de toegang voor de coach en annuleert open hulpvragen en voorstellen.", "importNotImplemented": "Activiteiten importeren is niet geïmplementeerd in R1. Jules gebruikt vooraf ingevulde onvolledige dekking.", "fixturePermission": "Nieuwe trajecten starten zonder optionele toestemming. De kant-en-klare scenario's Camille en Noor bevatten eerder verleende fictieve toestemming om met de coach te delen.", "supportTitle": "Een beetje ondersteuning?", "supportExplanation": "Vraag de toegewezen coach om je plan te bekijken. Er wordt geen antwoordtermijn beloofd.", "requestSupport": "Ondersteuning vragen", "enableSharing": "Schakel delen met de coach in voordat je ondersteuning vraagt.", "reviewFirst": "Bereken een weekoverzicht voordat je ondersteuning vraagt.", "goCoach": "Verder als Coach", "goMember": "Terug naar Deelnemer", "goAnalyst": "Bekijken als Analist", "history": "Geschiedenis van aanvaarde plannen", "slots": "wandelblokken", "status_requested": "Ondersteuning gevraagd", "status_proposed": "Voorstel wacht op jouw keuze", "status_closed": "Hulpvraag afgesloten", "status_cancelled": "Hulpvraag geannuleerd na intrekking", "assignedToYou": "TOEGEWEZEN AAN COACH ALEX", "sharedContext": "Gedeelde programmacontext voor deze fictieve deelnemer.", "waitingAcceptance": "Voorstel bewaard. Het huidige plan blijft ongewijzigd terwijl de deelnemer beslist.", "proposeAdjustment": "Een kleiner schema voorstellen", "coachExample": "Voorbeeld: behoud de eerste twee huidige wandeldagen met telkens 10 minuten. Leg uit waarom dit beter zou kunnen passen.", "rationale": "Reden voor het voorstel", "sendProposal": "Voorstel aan de deelnemer bezorgen", "emptyQueue": "Geen open toegewezen hulpvragen. De deelnemer moet eerst zelf hulp vragen.", "memberDecides": "De deelnemer beslist.", "revocationExplanation": "De toestemming om te delen wordt opnieuw gecontroleerd bij elke beschermde lees- en schrijfactie.", "decisionHistory": "Gepseudonimiseerde beslissingsgeschiedenis", "analystPrivacy": "Alleen regelcategorieën en versies. Geen namen, contactgegevens of persoonlijke notities.", "noDecisions": "Nog geen weekoverzichten berekend. Ga terug naar Deelnemer en bereken een overzicht.", "reviewRevision": "Revisie van het weekoverzicht", "notReported": "Niet gemeld", "dataQuality": "Gegevenskwaliteit", "saved": "Bewaard. Je werkruimte toont het vastgelegde resultaat.", "requestSaved": "Hulpvraag bewaard. Ga verder als Coach om ze te bekijken.", "proposalSaved": "Voorstel bewaard. Ga terug naar Deelnemer om het te aanvaarden of af te wijzen. Het actieve plan is niet gewijzigd.", "reload": "Huidige gegevens herladen", "footer": "Fictieve case en gegevens. Geen echte meldingen, wearable-integratie of klinisch advies. Onafhankelijke portfoliodemo.", "outcomes": { "support_offered": "Ondersteuning is beschikbaar als je dat wilt", "data_check_needed": "Controleer de ontbrekende gegevens", "check_in_invited": "Een optionele check-in is beschikbaar", "plan_review_offered": "Je kunt het plan bekijken", "progress_acknowledged": "Geregistreerde vooruitgang erkend", "continue_or_adjust": "Verdergaan of een aanpassing kiezen" }, "errors": { "service_unavailable": "De dienst is niet beschikbaar. Je opslag is niet bevestigd. Controleer de lokale PostgreSQL en herlaad.", "network_error": "De verbinding is verbroken. Je opslag is niet bevestigd. Probeer dezelfde actie opnieuw zonder de velden te wijzigen.", "unauthenticated": "Je demosessie is verlopen. Herlaad om een nieuw afzonderlijk traject te starten.", "origin_forbidden": "De oorsprong van dit verzoek is niet toegestaan.", "invalid_json": "Het verzoek kon niet worden gelezen.", "payload_too_large": "Het verzoek is te groot.", "stale_generation": "Dit traject is in een ander tabblad herstart. De huidige gegevens zijn herladen. Bekijk ze voordat je opnieuw probeert.", "stale_actor": "Je rol is in een ander tabblad gewijzigd. De huidige gegevens zijn herladen. Bekijk ze voordat je opnieuw probeert.", "idempotency_conflict": "Deze herhaalsleutel is voor een andere actie gebruikt. Herlaad en probeer opnieuw.", "missing_idempotency_key": "Een geldige herhaalsleutel is vereist.", "permission_revoked": "Delen met de coach is uitgeschakeld. Ga terug naar Deelnemer om je toestemmingskeuzes te bekijken.", "role_forbidden": "Deze actie is niet beschikbaar voor de huidige rol.", "resource_not_found": "Deze gegevens zijn niet beschikbaar voor de huidige gebruiker.", "invalid_preferences": "Vul een doel, unieke beschikbare dagen en een positief geheel aantal minuten in.", "no_suitable_template": "Geen demomodel past bij deze voorkeuren. Probeer wandelen in blokken van 10 minuten. Er is geen nieuw voorschrift gemaakt.", "invalid_slots": "Kies unieke wandeldagen met blokken van 10 minuten.", "invalid_date": "Gebruik een geldige datum op of vóór 11 oktober 2026. Weekoverzichten gebruiken de week vanaf 5 oktober.", "invalid_duration": "Vul een geheel aantal minuten tussen 1 en 1440 in.", "invalid_activity": "Deze demo registreert alleen wandelactiviteiten.", "invalid_check_in": "Controleer de check-in en de lengte van de optionele notitie.", "invalid_consent": "Kies of je toestemming geeft om te delen.", "invalid_request": "De hulpvraag vereist een actueel weekoverzicht van je actieve plan.", "invalid_transition": "Deze actie past niet bij de huidige status. Herlaad en bekijk de volgende stap.", "duplicate_request": "Er is al een open hulpvraag voor dit plan.", "stale_version": "Het voorstel verwijst naar een oudere planversie. Herlaad voordat je verdergaat.", "etag_mismatch": "Het plan is gewijzigd. De huidige gegevens zijn herladen. Bekijk ze voordat je aanvaardt.", "precondition_required": "Herlaad om de huidige planversie op te halen voordat je verdergaat.", "invalid_rationale": "Voeg een reden van maximaal 500 tekens toe.", "invalid_decision": "Kies aanvaarden of afwijzen.", "invalid_role": "Kies Deelnemer, Coach of Analist.", "invalid_scenario": "Kies een van de beschikbare demoscenario's." }, "finishReporting": "Registratie afronden zonder check-in", "completedGuide": "De aanpassing is aanvaard. Bekijk de Analist-weergave om het eerdere weekoverzicht te volgen. De momentopname en je oorspronkelijke planversie blijven bewaard." } ===== migrations/001_foundation.sql ===== CREATE TABLE demo_sessions ( id uuid PRIMARY KEY, token_hash text UNIQUE NOT NULL, actor text NOT NULL DEFAULT 'member' CHECK (actor IN ('member','coach','analyst')), member_id text NOT NULL DEFAULT 'm-camille', scenario text NOT NULL DEFAULT 'guided' CHECK (scenario IN ('guided','camille','jules','noor')), generation integer NOT NULL DEFAULT 1, revision integer NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL DEFAULT now() + interval '24 hours' ); CREATE TABLE members (session_id uuid REFERENCES demo_sessions ON DELETE CASCADE, id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id)); CREATE TABLE plans (session_id uuid, id text NOT NULL, member_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,member_id) REFERENCES members ON DELETE CASCADE); CREATE UNIQUE INDEX one_current_plan ON plans(session_id,member_id) WHERE data->>'state' IN ('active','proposed'); CREATE TABLE plan_versions (session_id uuid, id text NOT NULL, plan_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,plan_id) REFERENCES plans ON DELETE CASCADE); CREATE TABLE activities (session_id uuid, id text NOT NULL, member_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,member_id) REFERENCES members ON DELETE CASCADE); CREATE TABLE check_ins (session_id uuid, id text NOT NULL, plan_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,plan_id) REFERENCES plans ON DELETE CASCADE); CREATE TABLE reviews (session_id uuid, id text NOT NULL, plan_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,plan_id) REFERENCES plans ON DELETE CASCADE); CREATE TABLE support_requests (session_id uuid, id text NOT NULL, member_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,member_id) REFERENCES members ON DELETE CASCADE); CREATE UNIQUE INDEX one_unresolved_support ON support_requests(session_id,(data->>'planId')) WHERE data->>'state' IN ('requested','proposed'); CREATE TABLE plan_changes (session_id uuid, id text NOT NULL, plan_id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id), FOREIGN KEY(session_id,plan_id) REFERENCES plans ON DELETE CASCADE); CREATE TABLE audit_events (session_id uuid REFERENCES demo_sessions ON DELETE CASCADE, id text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,id)); CREATE TABLE idempotency_results (session_id uuid REFERENCES demo_sessions ON DELETE CASCADE, generation integer NOT NULL, key text NOT NULL, fingerprint text NOT NULL, data jsonb NOT NULL, PRIMARY KEY(session_id,generation,key)); CREATE INDEX session_expiry ON demo_sessions(expires_at); ===== next.config.ts ===== import createNextIntlPlugin from 'next-intl/plugin'; export default createNextIntlPlugin('./src/i18n/request.ts')({ poweredByHeader: false, output: 'standalone', async rewrites() { return {beforeFiles: [{source: '/evidence/:document.md', destination: '/evidence/rendered/:document.html'}]}; }, experimental: {cpus: 1} }); ===== package.json ===== { "name": "my-prevention-plan", "version": "0.1.0", "private": true, "scripts": { "predev": "node scripts/render-evidence.mjs", "prebuild": "node scripts/render-evidence.mjs", "dev": "next dev --webpack -H 127.0.0.1", "build": "next build --webpack", "start": "next start -H 127.0.0.1", "typecheck": "tsc --noEmit", "test": "vitest run tests/unit", "test:integration": "vitest run tests/integration", "test:e2e": "playwright test", "db:migrate": "node --env-file=.env scripts/database.mjs migrate", "db:cleanup": "node --env-file=.env scripts/database.mjs cleanup", "db:local": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/local-postgres.ps1" }, "engines": { "node": ">=24.0.0" }, "dependencies": { "next": "16.4.0", "next-intl": "4.14.9", "pg": "8.23.1", "react": "19.3.0", "react-dom": "19.3.0" }, "devDependencies": { "@playwright/test": "1.64.0", "@tailwindcss/postcss": "4.3.3", "@types/node": "24.10.1", "@types/pg": "8.15.6", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "markdown-it": "15.0.2", "mermaid": "12.2.0", "tailwindcss": "4.3.3", "typescript": "5.9.3", "vitest": "5.0.4" } } ===== playwright.config.ts ===== import {defineConfig, devices} from '@playwright/test'; export default defineConfig({testDir: './tests/e2e', workers: 1, use: {baseURL: 'http://127.0.0.1:3000', trace: 'retain-on-failure'}, projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}], webServer: {command: 'npm run start', url: 'http://127.0.0.1:3000', reuseExistingServer: false, timeout: 60000}}); ===== scripts/create-local-databases.mjs ===== import {Client} from 'pg'; const url = new URL(process.env.DATABASE_URL); if (url.hostname !== '127.0.0.1' || url.port !== '55432') throw new Error('Only the isolated local cluster is allowed'); url.pathname = '/postgres'; const db = new Client({connectionString:url.toString()}); await db.connect(); try {for (const name of ['prevention_dev','prevention_test']) if (!(await db.query('SELECT 1 FROM pg_database WHERE datname=$1',[name])).rowCount) {await db.query(`CREATE DATABASE ${name}`); console.log(`Created ${name}`);}} finally {await db.end();} ===== scripts/database.mjs ===== import {Pool} from 'pg'; import {readdir, readFile} from 'node:fs/promises'; const url = process.env.DATABASE_URL; if (!url) throw new Error('Configure DATABASE_URL in Code/.env'); const db = new Pool({connectionString: url}); try { if (process.argv[2] === 'cleanup') { const result = await db.query('DELETE FROM demo_sessions WHERE expires_at <= now()'); console.log(`Expired sessions removed: ${result.rowCount}`); } else { const client = await db.connect(); try { await client.query('BEGIN'); await client.query("SELECT pg_advisory_xact_lock(723981)"); await client.query('CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz DEFAULT now())'); for (const name of (await readdir(new URL('../migrations/', import.meta.url))).filter(n => n.endsWith('.sql')).sort()) { if ((await client.query('SELECT 1 FROM schema_migrations WHERE name=$1', [name])).rowCount) continue; await client.query(await readFile(new URL(`../migrations/${name}`, import.meta.url), 'utf8')); await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [name]); console.log(`Applied ${name}`); } await client.query('COMMIT'); } catch (e) {await client.query('ROLLBACK'); throw e;} finally {client.release();} } } finally {await db.end();} ===== scripts/local-postgres.ps1 ===== param([ValidateSet('start','stop')][string]$Action = 'start') $ErrorActionPreference = 'Stop' $codeRoot = Split-Path $PSScriptRoot -Parent if ($codeRoot.StartsWith('H:', [StringComparison]::OrdinalIgnoreCase)) { throw 'Use scripts/run-local.ps1 to run from a local NTFS workspace, not Google Drive.' } $localRoot = Join-Path $codeRoot '.local' $bin = Join-Path $localRoot 'pgsql\bin' $data = Join-Path $localRoot 'pgdata' $envFile = Join-Path $codeRoot '.env' if ($Action -eq 'stop') { & (Join-Path $bin 'pg_ctl.exe') -D $data -m fast stop; exit $LASTEXITCODE } New-Item -ItemType Directory -Force -Path $localRoot | Out-Null if (!(Test-Path -LiteralPath (Join-Path $bin 'initdb.exe'))) { $zipFile = Join-Path $localRoot 'postgresql.zip' if (!(Test-Path -LiteralPath $zipFile)) { Invoke-WebRequest -UseBasicParsing -Uri 'https://sbp.enterprisedb.com/getfile.jsp?fileid=1260616' -OutFile $zipFile } Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [IO.Compression.ZipFile]::OpenRead($zipFile) try { foreach ($entry in $archive.Entries) { if ($entry.FullName -notmatch '^pgsql/(bin|lib|share)/' -or !$entry.Name) { continue } $target = [IO.Path]::GetFullPath((Join-Path $localRoot $entry.FullName)) if (!$target.StartsWith(([IO.Path]::GetFullPath($localRoot) + '\'), [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe archive path' } New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) } } finally { $archive.Dispose() } } if (!(Test-Path -LiteralPath (Join-Path $data 'PG_VERSION'))) { if (Test-Path -LiteralPath $envFile) { throw 'Existing .env found. Configure PostgreSQL manually; it will not be overwritten.' } $bytes = New-Object byte[] 32 $rng = [Security.Cryptography.RandomNumberGenerator]::Create(); $rng.GetBytes($bytes); $rng.Dispose() $password = [BitConverter]::ToString($bytes).Replace('-','').ToLowerInvariant() [IO.File]::WriteAllText($envFile, $password + "`n") & (Join-Path $bin 'initdb.exe') -D $data -U postgres --encoding=UTF8 --locale=C --auth=scram-sha-256 --pwfile=$envFile if ($LASTEXITCODE -ne 0) { throw 'initdb failed' } Add-Content -LiteralPath (Join-Path $data 'postgresql.conf') -Value "`nlisten_addresses = '127.0.0.1'`nport = 55432`n" [IO.File]::WriteAllText($envFile, "DATABASE_URL=postgresql://postgres:${password}@127.0.0.1:55432/prevention_dev`nTEST_DATABASE_URL=postgresql://postgres:${password}@127.0.0.1:55432/prevention_test`nAPP_ORIGIN=http://127.0.0.1:3000`n") } $statusProcess = Start-Process -FilePath (Join-Path $bin 'pg_ctl.exe') -ArgumentList @('-D', ('"' + $data + '"'), 'status') -WindowStyle Hidden -Wait -PassThru -RedirectStandardOutput (Join-Path $localRoot 'postgres-status.log') -RedirectStandardError (Join-Path $localRoot 'postgres-status-error.log') if ($statusProcess.ExitCode -ne 0) { & (Join-Path $bin 'pg_ctl.exe') -D $data -l (Join-Path $localRoot 'postgresql.log') -w start if ($LASTEXITCODE -ne 0) { throw 'PostgreSQL startup failed' } } Push-Location $codeRoot try { node --env-file=.env scripts/create-local-databases.mjs; if ($LASTEXITCODE -ne 0) { throw 'Database creation failed' } } finally { Pop-Location } ===== scripts/migrate-test.mjs ===== import {spawnSync} from 'node:child_process'; const url = new URL(process.env.TEST_DATABASE_URL); if (!['127.0.0.1','localhost'].includes(url.hostname) || url.pathname !== '/prevention_test' || process.env.TEST_DATABASE_URL === process.env.DATABASE_URL) throw new Error('Use an isolated local prevention_test database'); const result = spawnSync(process.execPath,['scripts/database.mjs','migrate'], {stdio:'inherit',env:{...process.env,DATABASE_URL:process.env.TEST_DATABASE_URL}}); process.exit(result.status ?? 1); ===== scripts/preview.ps1 ===== param([switch]$HostProcess) $ErrorActionPreference = 'Stop' $runtimePath = Join-Path $env:LOCALAPPDATA 'PreventionPlanRuntime' $origin = 'http://127.0.0.1:3000' if ($HostProcess) { Set-Location -LiteralPath $runtimePath $log = Join-Path $runtimePath '.local\application.log' try { # Do not wrap pg_ctl startup in a PowerShell pipeline: daemon descendants # can retain its output handles and prevent this host from reaching Next.js. & ./scripts/local-postgres.ps1 if ($LASTEXITCODE -ne 0) { throw 'Local PostgreSQL startup failed' } $node = (Get-Command node.exe -ErrorAction Stop).Source $app = Start-Process -FilePath $node -ArgumentList @('node_modules/next/dist/bin/next','start','-H','127.0.0.1') -WorkingDirectory $runtimePath -WindowStyle Hidden -Wait -PassThru -RedirectStandardOutput $log -RedirectStandardError (Join-Path $runtimePath '.local\application-error.log') exit $app.ExitCode } catch { Add-Content -LiteralPath $log -Value ("Local preview startup failed at {0}:{1}, category {2}." -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.FullyQualifiedErrorId) exit 1 } } & (Join-Path $PSScriptRoot 'run-local.ps1') sync if (!(Test-Path -LiteralPath (Join-Path $runtimePath '.next\BUILD_ID'))) { throw 'Build the application first using scripts/run-local.ps1 build.' } function Test-Preview { try { $response = Invoke-WebRequest -Uri "$origin/api/v1/demo/session" -UseBasicParsing -TimeoutSec 3 return $response.StatusCode -eq 200 } catch { # A missing cookie must give the application's JSON 401, proving the API is listening. return $_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 401 } } if (!(Test-Preview)) { if (Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 3000 is occupied by another process. It has not been stopped.' } $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $hostScript = Join-Path $runtimePath 'scripts\preview.ps1' # WMI owns this hidden host process, independently of the terminal/agent process. $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow = [uint16]0} $result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = '"' + $powershell + '" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $hostScript + '" -HostProcess' CurrentDirectory = $runtimePath ProcessStartupInformation = $startup } if ($result.ReturnValue -ne 0) { throw "Independent preview launch failed (WMI code $($result.ReturnValue))." } Set-Content -LiteralPath (Join-Path $runtimePath '.local\preview-host.pid') -Value $result.ProcessId } for ($attempt = 0; $attempt -lt 40; $attempt++) { if (Test-Preview) { $page = Invoke-WebRequest -Uri "$origin/en/demo" -UseBasicParsing -TimeoutSec 5 if ($page.StatusCode -eq 200) { Write-Output "Preview ready: $origin/en/demo"; exit 0 } } Start-Sleep -Milliseconds 250 } throw 'Preview did not become ready. Check the runtime .local/application.log, application-error.log and postgresql.log.' ===== scripts/render-evidence.mjs ===== import {mkdir, readdir, readFile, writeFile, copyFile, cp, stat} from 'node:fs/promises'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; import MarkdownIt from 'markdown-it'; const markdown = new MarkdownIt({html: false, linkify: true}); const escape = markdown.utils.escapeHtml; const defaultFence = markdown.renderer.rules.fence; markdown.renderer.rules.fence = (tokens, index, options, env, renderer) => { if (tokens[index].info.trim() !== 'mermaid') return defaultFence(tokens, index, options, env, renderer); env.hasDiagrams = true; return `

Loading diagram. Scroll horizontally to see wide diagrams.

Diagram source
${escape(tokens[index].content)}
\n`; }; const defaultLink = markdown.renderer.rules.link_open; markdown.renderer.rules.link_open = (tokens, index, options, env, renderer) => { const href = tokens[index].attrGet('href'); if (href?.startsWith('../')) { const target = href.split('/').at(-1); if (env.files?.has(target.split(/[?#]/)[0])) tokens[index].attrSet('href', target); } return defaultLink ? defaultLink(tokens, index, options, env, renderer) : renderer.renderToken(tokens, index, options); }; // Keep wide tables scrollable inside the document rather than widening the page. markdown.renderer.rules.table_open = () => '
\n'; markdown.renderer.rules.table_close = () => '
\n'; export function renderDocument(source, filename, files = new Set()) { source = source.replace(/^\uFEFF/, ''); const title = source.match(/^#\s+(.+)$/m)?.[1] ?? filename; const env = {files}; const body = markdown.render(source, env); return ` ${escape(title)} | My Prevention Plan

MY PREVENTION PLAN / DESIGN EVIDENCE

${body}
${env.hasDiagrams ? '' : ''}\n`; } export async function renderEvidence(root = fileURLToPath(new URL('../', import.meta.url))) { const evidence = path.join(root, 'public', 'evidence'); await mkdir(path.join(evidence, 'rendered'), {recursive: true}); await mkdir(path.join(evidence, 'raw'), {recursive: true}); // Host the pinned browser renderer and its lazy-loaded chunks on this site. const require = createRequire(import.meta.url); const mermaid = path.dirname(require.resolve('mermaid/package.json')); await cp(path.join(mermaid, 'dist'), path.join(evidence, 'vendor', 'mermaid'), { recursive: true, filter: async source => (await stat(source)).isDirectory() || source.endsWith('.mjs') }); await copyFile(path.join(mermaid, 'LICENSE'), path.join(evidence, 'vendor', 'mermaid', 'LICENSE')); let count = 0; const files = new Set(await readdir(evidence)); for (const filename of files) { if (!filename.endsWith('.md')) continue; const source = await readFile(path.join(evidence, filename), 'utf8'); await writeFile(path.join(evidence, 'rendered', filename.replace(/\.md$/, '.html')), renderDocument(source, filename, files)); await copyFile(path.join(evidence, filename), path.join(evidence, 'raw', filename)); count++; } console.log(`Rendered ${count} evidence documents`); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await renderEvidence(); ===== scripts/run-local.ps1 ===== param([ValidateSet('setup','dev','build','start','typecheck','test','integration','e2e','sync','stop')][string]$Task = 'dev') $ErrorActionPreference = 'Stop' $source = Split-Path $PSScriptRoot -Parent $runtimePath = Join-Path $env:LOCALAPPDATA 'PreventionPlanRuntime' New-Item -ItemType Directory -Force -Path $runtimePath | Out-Null if ([IO.Path]::GetFullPath($source) -ne [IO.Path]::GetFullPath($runtimePath)) { robocopy $source $runtimePath /E /XD .git .next .local node_modules playwright-report test-results /XF .env .env.local /NFL /NDL /NJH /NJS if ($LASTEXITCODE -ge 8) { throw 'Runtime synchronization failed' } } Push-Location $runtimePath try { if ($Task -eq 'sync') { Write-Output "Runtime synchronized: $runtimePath"; exit 0 } if ($Task -eq 'stop') { & ./scripts/local-postgres.ps1 stop; exit $LASTEXITCODE } $env:NODE_OPTIONS = '--use-system-ca' if ($Task -in @('dev','start','integration','e2e')) { & ./scripts/local-postgres.ps1 } if ($Task -eq 'setup') { npm ci; if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed' } & ./scripts/local-postgres.ps1 npm run db:migrate; if ($LASTEXITCODE -ne 0) { throw 'Migration failed' } node --env-file=.env scripts/migrate-test.mjs if ($LASTEXITCODE -ne 0) { throw 'Test database migration failed' } npx playwright install chromium } elseif ($Task -eq 'integration') { npm run test:integration } elseif ($Task -eq 'e2e') { npm run test:e2e } else { npm run $Task } if ($LASTEXITCODE -ne 0) { throw "Task failed: $Task" } } finally {Pop-Location} ===== scripts/source-revision.mjs ===== import {createHash} from 'node:crypto'; import {readFile,readdir,writeFile} from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; const root=fileURLToPath(new URL('../',import.meta.url));const files=[]; async function scan(dir){for(const entry of await readdir(path.join(root,dir),{withFileTypes:true})){const f=path.join(dir,entry.name);if(entry.isDirectory())await scan(f);else files.push(f.replaceAll('\\','/'));}} for(const dir of ['src','messages','migrations','tests','scripts','contracts','fixtures'])await scan(dir); files.push('package.json','package-lock.json','tsconfig.json','next.config.ts','postcss.config.mjs','playwright.config.ts','vitest.config.ts'); const aggregate=createHash('sha256');const manifest=[]; for(const file of files.sort()){const data=await readFile(path.join(root,file));const sha256=createHash('sha256').update(data).digest('hex');aggregate.update(file+'\0'+sha256+'\n');manifest.push({file,sha256});} const result={algorithm:'SHA-256',revision:aggregate.digest('hex'),files:manifest}; if(process.argv[2])await writeFile(process.argv[2],JSON.stringify(result,null,2)+'\n'); console.log(result.revision); ===== scripts/sync-evidence.mjs ===== import {mkdir, readdir, readFile, writeFile, copyFile} from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; const root = fileURLToPath(new URL('../', import.meta.url)); const documentation = process.argv[2]; if (!documentation) throw new Error('Pass the Project documentation folder explicitly'); const destination = path.join(root,'public','evidence'); await mkdir(destination,{recursive:true}); for (const name of await readdir(path.join(documentation,'FA Deliverables'))) if (name.endsWith('.md')) await writeFile(path.join(destination,name),(await readFile(path.join(documentation,'FA Deliverables',name),'utf8')).replaceAll('\u2014','-')); for (const name of ['r1-devready-tickets.md','r1-process.bpmn','r1-uml.md','verification.md','ai-delivery-evidence.md']) await copyFile(path.join(documentation,name),path.join(destination,name)); await mkdir(path.join(destination,'verification-assets'),{recursive:true}); for(const name of await readdir(path.join(documentation,'verification-assets'))) if(/\.(png|json)$/.test(name)) await copyFile(path.join(documentation,'verification-assets',name),path.join(destination,'verification-assets',name)); await copyFile(path.join(root,'contracts','openapi.json'),path.join(destination,'openapi.json')); const files=[]; async function scan(dir) {for(const entry of await readdir(path.join(root,dir),{withFileTypes:true})) {const relative=path.join(dir,entry.name);if(entry.isDirectory()) await scan(relative);else if(/\.(tsx?|mjs|ps1|sql|json|css)$/.test(entry.name)) files.push(relative);}} for(const folder of ['src','tests','messages','migrations','scripts']) await scan(folder); files.push('package.json','next.config.ts','playwright.config.ts','vitest.config.ts'); const source=await Promise.all(files.sort().map(async f => `\n===== ${f.replaceAll('\\','/')} =====\n${await readFile(path.join(root,f),'utf8')}`)); await writeFile(path.join(destination,'implementation-source.txt'),`My Prevention Plan source snapshot. Generated from an explicit allowlist; environment files and runtime data are excluded.\n${source.join('\n')}`); console.log(`Exported FA documents and ${files.length} source files`); ===== scripts/update-r1-contract.mjs ===== import {readFile,writeFile} from 'node:fs/promises'; const url = new URL('../contracts/openapi.json',import.meta.url); const api=JSON.parse(await readFile(url,'utf8')); api.info.version='0.2.0';api.info.description='Portfolio R1 local implementation. Synthetic data only. POC extensions are explicitly marked planned.'; const implemented = new Set(['/members/me','/members/me/consents/{purpose}','/plans','/plans/{planId}','/activities','/plans/{planId}/check-ins/{weekStart}','/plans/{planId}/reviews','/support-requests','/coach/support-requests','/plans/{planId}/changes','/plans/{planId}/changes/{changeId}/decision','/analyst/decisions']); for(const [p,item] of Object.entries(api.paths)) for(const [method,op] of Object.entries(item)) { if (!['get','post','patch','put','delete'].includes(method)) continue; op['x-r1-status']=implemented.has(p) && !(p==='/activities' && !['get','post'].includes(method)) ? 'implemented-local':'planned-poc'; if(op['x-r1-status']==='implemented-local' && method !=='get') op.parameters=[...(op.parameters??[]).filter(p=>!['X-Demo-Generation','X-Demo-Revision'].includes(p.name)), ...['X-Demo-Generation','X-Demo-Revision'].map(name=>({in:'header',name,required:true,schema:{type:'integer',minimum:1}}))]; } const schemas=api.components.schemas; schemas.DemoSession={type:'object',required:['generation','revision','role','scenario','expiresAt'],properties:{generation:{type:'integer'},revision:{type:'integer'},role:{enum:['member','coach','analyst']},scenario:{enum:['guided','camille','jules','noor']},expiresAt:{type:'string',format:'date-time'}}}; const controls={ '/demo/start':['post','startDemo',{type:'object'},false], '/demo/session':['get','readDemoSession',null,true], '/demo/state':['get','readRoleScopedDemoState',null,true], '/demo/role':['post','switchDemoRole',{type:'object',required:['role'],properties:{role:{enum:['member','coach','analyst']}}},true], '/demo/scenario':['post','loadDemoScenario',{type:'object',required:['scenario'],properties:{scenario:{enum:['guided','camille','jules','noor']}}},true], '/demo/reset':['post','resetOwnDemo',{type:'object'},true] }; for(const [p,[method,operationId,schema,authorized]] of Object.entries(controls)) { const op={operationId,summary:operationId,'x-r1-status':'implemented-local',security:authorized?[{demoSession:[]}]:[],responses:{'200':{description:'Current session or role-scoped state, no-store',content:{'application/json':{schema:p==='/demo/state'?{type:'object',description:'Session envelope plus member/coach programme context, or analyst decisions. Protected coach view fails when sharing is off.'}:{$ref:'#/components/schemas/DemoSession'}}}},'401':{description:'Session missing or expired'},'409':{description:'Stale generation/actor or idempotency conflict'},'503':{description:'Save not confirmed'}}}; if(schema) op.requestBody={required:true,content:{'application/json':{schema}}}; if(schema && authorized) op.parameters=[{in:'header',name:'Idempotency-Key',required:true,schema:{type:'string',minLength:8,maxLength:100}},...['X-Demo-Generation','X-Demo-Revision'].map(name=>({in:'header',name,required:true,schema:{type:'integer',minimum:1}}))]; api.paths[p]={[method]:op}; } api.components.securitySchemes.demoSession={type:'apiKey',in:'cookie',name:'prevention_demo',description:'Opaque HttpOnly SameSite=Lax token. Only its SHA-256 digest is stored. Session actor is server-controlled.'}; api.paths['/activities'].get={operationId:'listActivities','x-r1-status':'implemented-local',responses:{'200':{description:'Own manual activity items'}}}; api.paths['/plans/{planId}/changes'].get={operationId:'listPlanChanges','x-r1-status':'implemented-local',parameters:[{in:'path',name:'planId',required:true,schema:{type:'string'}}],responses:{'200':{description:'Owner or authorized assigned-coach change history'}}}; // Preserve the full-POC schemas and use separate R1 schemas for the narrowed contract. const clone = name => structuredClone(schemas[name]); const ref = name => ({$ref:`#/components/schemas/${name}`}); const object = (properties,required=[]) => ({type:'object',properties,required,additionalProperties:false}); schemas.R1Member=clone('Member');delete schemas.R1Member.properties.locale;delete schemas.R1Member.properties.timezone;schemas.R1Member.required=schemas.R1Member.required.filter(p=>!['locale','timezone'].includes(p));schemas.R1Member.properties.reportingComplete={type:'boolean'}; schemas.R1MemberUpdate={anyOf:[ref('Preferences'),object({preferences:ref('Preferences')},['preferences']),object({reportingComplete:{type:'boolean'}},['reportingComplete'])]}; schemas.R1Plan=clone('Plan');schemas.R1Plan.properties.state.enum=['proposed','active','declined'];schemas.R1Plan.properties.currentVersion.minimum=0; schemas.R1PlanVersion=clone('PlanVersion');schemas.R1PlanVersion.properties.id={type:'string'};schemas.R1PlanVersion.properties.planId={type:'string'};schemas.R1Plan.properties.versions.items=ref('R1PlanVersion'); schemas.R1PlanAction=object({action:{enum:['accept','decline']}},['action']); schemas.R1ConsentChoice=object({granted:{type:'boolean'}},['granted']); schemas.R1CheckInInput=clone('CheckInInput');schemas.R1CheckInInput.properties.note.maxLength=500;schemas.R1CheckInInput.properties.barriers.maxItems=3; schemas.R1CheckIn=clone('CheckIn');schemas.R1CheckIn.properties.note.maxLength=500;schemas.R1CheckIn.properties.barriers.maxItems=3; schemas.R1ReviewRequest=object({weekStart:{enum:['2026-10-05']}},['weekStart']); schemas.R1Review=clone('Review');schemas.R1Review.properties.recordedCount={type:'integer',minimum:0};schemas.R1Review.properties.createdAt={type:'string',format:'date-time'};schemas.R1Review.properties.snapshot=object({difficulty:{type:['string','null']},reportingComplete:{type:'boolean'},recordedCount:{type:'integer'}},['difficulty','reportingComplete','recordedCount']); schemas.R1Support=clone('SupportRequest');schemas.R1Support.properties.state.enum=['requested','proposed','closed','cancelled']; schemas.R1ChangeInput=clone('ChangeInput');schemas.R1ChangeInput.properties.rationale.maxLength=500; schemas.R1Change=clone('PlanChange');schemas.R1Change.properties.state.enum.push('cancelled');schemas.R1Change.properties.rationale.maxLength=500; schemas.R1Decision=object({id:{type:'string'},memberRef:{type:'string'},ruleId:{type:'string'},ruleVersion:{type:'string'},outcome:{type:'string'},inputCategories:object({difficulty:{type:['string','null']},completeness:{enum:['complete','incomplete']},recordedCount:{type:'integer'}},['difficulty','completeness','recordedCount']),planVersion:{type:'integer'},reviewRevision:{type:'integer'},templateId:{type:'string'},templateVersion:{type:'integer'},createdAt:{type:'string',format:'date-time'}},['id','memberRef','ruleId','ruleVersion','outcome','inputCategories','planVersion','reviewRevision','templateId','templateVersion','createdAt']); const requestOverrides=[['/members/me','patch','R1MemberUpdate'],['/members/me/consents/{purpose}','put','R1ConsentChoice'],['/plans/{planId}','patch','R1PlanAction'],['/plans/{planId}/check-ins/{weekStart}','put','R1CheckInInput'],['/plans/{planId}/reviews','post','R1ReviewRequest'],['/plans/{planId}/changes','post','R1ChangeInput']]; for(const [p,m,n]of requestOverrides)api.paths[p][m].requestBody.content['application/json'].schema=ref(n); api.paths['/plans'].post.requestBody.content['application/json'].schema=object({}); const responseOverrides=[['/members/me','get','R1Member'],['/members/me','patch','R1Member'],['/plans','post','R1Plan'],['/plans/{planId}','get','R1Plan'],['/plans/{planId}','patch','R1Plan'],['/plans/{planId}/check-ins/{weekStart}','put','R1CheckIn'],['/plans/{planId}/reviews','post','R1Review'],['/support-requests','post','R1Support'],['/plans/{planId}/changes','post','R1Change'],['/plans/{planId}/changes/{changeId}/decision','post','R1Plan']]; for(const[p,m,n]of responseOverrides){api.paths[p][m].responses['200']??={description:'Successful R1 operation'};api.paths[p][m].responses['200'].content={'application/json':{schema:ref(n)}};} for(const[p,n]of [['/coach/support-requests','R1Support'],['/plans/{planId}/changes','R1Change'],['/analyst/decisions','R1Decision']])api.paths[p].get.responses['200'].content={'application/json':{schema:object({items:{type:'array',items:ref(n)}},['items'])}}; for(const p of ['/plans','/activities','/support-requests','/plans/{planId}/reviews','/plans/{planId}/changes']){api.paths[p].post.responses['201']=structuredClone(api.paths[p].post.responses['200']??api.paths[p].post.responses['201']);delete api.paths[p].post.responses['200'];} api.paths['/demo/start'].post.responses['201']=structuredClone(api.paths['/demo/start'].post.responses['200']); await writeFile(url,JSON.stringify(api,null,2)+'\n'); ===== scripts/verify-local.mjs ===== import {spawn} from 'node:child_process'; import {mkdir,writeFile} from 'node:fs/promises'; import {chromium} from '@playwright/test'; import path from 'node:path'; const origin='http://127.0.0.1:3001'; let server; const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); async function start() { server=spawn(process.execPath,['node_modules/next/dist/bin/next','start','-p','3001','-H','127.0.0.1'],{env:{...process.env,APP_ORIGIN:origin,NEXT_TELEMETRY_DISABLED:'1'},stdio:'ignore',windowsHide:true}); for(let i=0;i<100;i++){try{if((await fetch(origin+'/en')).ok)return;}catch{}await delay(150);} throw new Error('Production startup failed'); } async function stop(){if(!server || server.exitCode!==null)return;const exit=new Promise(resolve=>server.once('exit',resolve));server.kill();await exit;} let cookie='',generation=1,revision=1; async function api(route,body,method=body===undefined?'GET':'POST'){ const headers={Origin:origin,...(cookie?{Cookie:cookie}:{})}; if(body!==undefined)Object.assign(headers,{'Content-Type':'application/json','Idempotency-Key':crypto.randomUUID(),'X-Demo-Generation':String(generation),'X-Demo-Revision':String(revision)}); const response=await fetch(origin+'/api/v1/'+route,{method,headers,body:body===undefined?undefined:JSON.stringify(body)}); const data=await response.json();if(!response.ok)throw new Error(data.code); if(response.headers.get('set-cookie'))cookie=response.headers.get('set-cookie').split(';')[0]; if(data.generation){generation=data.generation;revision=data.revision;}return data; } const output=process.argv[2];if(!output)throw new Error('Pass an output evidence directory');await mkdir(output,{recursive:true}); try{ await start();await api('demo/start',{});await api('demo/scenario',{scenario:'camille'}); await api('activities',{occurredOn:'2026-10-07',durationMinutes:17,activityType:'walking'}); const before=await api('demo/state');await stop();await start();const after=await api('demo/state'); if(JSON.stringify(before)!==JSON.stringify(after))throw new Error('TC38 restart persistence mismatch'); const times=[];for(let i=0;i<100;i++){const t=performance.now();await api('plans/plan-camille/reviews',{weekStart:'2026-10-05'});times.push(performance.now()-t);} times.sort((a,b)=>a-b);const p95=Number(times[94].toFixed(2)); const browser=await chromium.launch(); try{ const page=await browser.newPage({viewport:{width:1440,height:1000}}); await page.goto(origin+'/en');await page.screenshot({path:path.join(output,'case-desktop.png'),fullPage:true}); const links=await page.locator('a[href^="/evidence/"]').evaluateAll(els=>els.map(e=>e.getAttribute('href'))); for(const link of links)if(!(await fetch(origin+link)).ok)throw new Error('Broken evidence link: '+link); await page.goto(origin+'/en/demo');await page.getByRole('heading',{name:'What fits your week?'}).waitFor(); await page.screenshot({path:path.join(output,'member-desktop.png'),fullPage:true}); await page.keyboard.press('Tab');await page.keyboard.press('Enter'); // The skip link must move keyboard focus into the main region. const focus=await page.evaluate(()=>document.activeElement?.id);if(focus!=='main')throw new Error('Skip-link keyboard focus failed'); await page.setViewportSize({width:360,height:800});await page.goto(origin+'/nl-BE/demo');await page.getByRole('heading',{name:'Wat past in je week?'}).waitFor(); await page.screenshot({path:path.join(output,'member-mobile-nl.png'),fullPage:true}); if(!(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)))throw new Error('Mobile overflow'); await page.getByLabel('Verken een scenario').selectOption('camille');await page.getByRole('button',{name:'Weekoverzicht berekenen'}).click();await page.getByRole('heading',{name:'Ondersteuning is beschikbaar als je dat wilt'}).waitFor(); await page.screenshot({path:path.join(output,'review-mobile-nl.png'),fullPage:true}); }finally{await browser.close();} const result={executedAt:new Date().toISOString(),runtime:process.version,postgresql:'17.11',restartPersistence:'passed',reviewBenchmark:{requests:100,p95Ms:p95,targetMs:1000,passed:p95<=1000,mode:'production HTTP, local PostgreSQL, sequential single visitor, growing immutable history'},evidenceLinks:'passed',mobileOverflow:'passed',keyboardSkipLink:'passed'}; await writeFile(path.join(output,'local-checks.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result,null,2)); if(p95>1000)throw new Error('NFR06 benchmark exceeded target'); }finally{await stop();} ===== src/app/[locale]/demo/page.tsx ===== import Demo from '@/components/demo'; export default function DemoPage() {return ;} ===== src/app/[locale]/layout.tsx ===== import {NextIntlClientProvider} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import {notFound} from 'next/navigation'; export const metadata = {title: 'My Prevention Plan | Portfolio demo', description: 'A fictional prevention-support case: member choice, coach handoff and traceable decisions.'}; export default async function LocaleLayout({children, params}: {children: React.ReactNode; params: Promise<{locale: string}>}) { const {locale} = await params; if (!['en','nl-BE'].includes(locale)) notFound(); setRequestLocale(locale); const messages = (await import(`../../../messages/${locale}.json`)).default; return {children}; } ===== src/app/[locale]/page.tsx ===== import Link from 'next/link'; export default async function CasePage({params}: {params: Promise<{locale: string}>}) { const {locale} = await params; return
mpMy Prevention Plan

FUNCTIONAL ANALYSIS · WORKING DEMONSTRATOR

A small plan.
A clear next step.

Making prevention support understandable, with member choice at every handoff.

Follow a fictional member from agreeing a walking plan to requesting support, reviewing a coach proposal and choosing whether to accept it. Then inspect the rule behind the decision.

Explore the guided demo

No registration. Your own isolated dataset. English and Belgian Dutch.

FROM ANALYSIS TO BEHAVIOUR

See how it was designed

Fictional case. Synthetic data only. This portfolio is independent of CM/MC. No clinical benefit, approved company specification or production readiness is claimed. Imports are seeded examples; real wearable integration and public VPS release remain separate work.
; } ===== src/app/api/v1/[...path]/route.ts ===== import {NextRequest} from 'next/server'; import {handle} from '@/server/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type Context = {params: Promise<{path: string[]}>}; async function route(req: NextRequest, ctx: Context) {return handle(req, (await ctx.params).path.join('/'));} export {route as GET, route as POST, route as PATCH, route as PUT, route as DELETE}; ===== src/app/globals.css ===== @import "tailwindcss"; :root{--ink:#173e39;--muted:#637772;--accent:#236853;--line:#dce5df;--paper:#f5f7f3;--mint:#eaf2e9;--gold:#eed3a1} *{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:16px;border:1px solid var(--accent);border-radius:7px;background:var(--accent);color:white;padding:11px 18px;cursor:pointer;text-decoration:none;font-weight:600}button:hover,.button:hover{filter:brightness(.93)}button:disabled{opacity:.5;cursor:not-allowed}.secondary{background:white;color:var(--accent);margin-top:16px}.quiet{border:0;padding:7px 10px;background:transparent;color:var(--ink);font-weight:400}a{color:var(--accent);text-underline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #b27517;outline-offset:3px}h1,h2,h3,p{margin:0 0 14px}h1{font-family:Georgia,serif;font-size:38px;font-weight:400;line-height:1.18;letter-spacing:-.8px}h2{font-size:22px;line-height:1.3;font-weight:600}h3{font-size:17px}small,.small{font-size:12px;line-height:1.6;color:var(--muted)}.muted{color:var(--muted)}.eyebrow{font-size:10px;letter-spacing:1.8px;font-weight:700;color:var(--muted)}.shell{display:grid;grid-template-columns:235px minmax(0,1fr);min-height:100vh}.sidebar{background:#fdfefa;border-right:1px solid var(--line);padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--ink);font-size:28px;letter-spacing:-1px;font-family:Georgia,serif;font-weight:bold;margin-bottom:50px}.brand>span{font-family:Arial,sans-serif;font-size:14px;line-height:1.35;letter-spacing:0}.role{width:100%;display:grid;grid-template-columns:25px 1fr;text-align:left;justify-content:start;color:var(--ink);background:transparent;border:0;margin:8px 0;padding:13px 12px;gap:0 8px;font-weight:600}.role small{grid-column:2;font-weight:400;font-size:11px}.role.selected{background:var(--mint);box-shadow:inset 3px 0 var(--accent)}.role-icon{grid-row:span 2;font-size:21px}.sidebar-bottom{margin-top:auto;padding-top:40px;font-size:12px}.sidebar-bottom p{margin-top:12px}.pill{display:inline-block;font-size:11px;border-radius:20px;border:1px solid var(--line);background:#f8faf5;padding:3px 10px;white-space:normal}.topbar{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:18px 38px;background:#fbfcf8;font-size:12px}.toolbar{display:flex;align-items:center;gap:20px}.content main{padding:35px 38px;max-width:1320px;margin:auto}.page-title{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:25px}.scenario{max-width:225px;font-size:11px;color:var(--muted)}.scenario select{font-size:12px;background:transparent}.guide{display:flex;align-items:start;gap:18px;padding:19px 22px;background:var(--mint);border:1px solid #d6e3d3;border-radius:8px;margin-bottom:22px}.guide strong{font-size:10px;letter-spacing:1.7px}.guide p{font-size:13px;margin:4px 0 0}.guide-icon{font-size:26px;color:var(--accent)}.steps{display:flex;list-style:none;padding:0;margin:28px 0;gap:10px;justify-content:space-between;flex-wrap:wrap;font-size:11px;color:var(--muted)}.steps li{display:flex;gap:6px;align-items:center}.steps span{display:grid;place-items:center;width:23px;height:23px;border:1px solid var(--line);border-radius:50%}.steps .reached span{background:var(--accent);color:white;border-color:var(--accent)}.main-grid{display:grid;grid-template-columns:minmax(0,1.9fr) minmax(230px,1fr);gap:22px}.stack{display:flex;flex-direction:column;gap:22px}.card{padding:26px;background:white;border:1px solid var(--line);border-radius:9px;box-shadow:0 2px 3px #193b2510;min-width:0}.tinted{background:#f0f4ec}.card-heading{display:flex;justify-content:space-between;align-items:start;gap:12px}.slots{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}.slots>div{border:1px solid var(--line);border-radius:7px;padding:16px;flex:1;min-width:110px;background:#f8faf6;display:flex;flex-direction:column;font-size:12px}.slots strong{font-family:Georgia,serif;font-size:30px;margin:7px 0}.slots small{font-family:Arial,sans-serif;font-weight:400}details{font-size:12px;margin:16px 0}summary{cursor:pointer;color:var(--accent)}details p{margin-top:12px}.actions,.form-row{display:flex;gap:14px;flex-wrap:wrap}.actions{margin-top:20px}.actions .secondary{margin:0}.form-row>label{flex:1;min-width:140px}label{display:block;font-size:13px;margin:0 0 16px;font-weight:600}input:not([type=checkbox]),select,textarea{display:block;width:100%;border:1px solid #bdcfc3;border-radius:6px;background:white;color:var(--ink);padding:10px 12px;margin-top:7px;min-width:0}textarea{min-height:90px;resize:vertical}input[type=checkbox]{accent-color:var(--accent);width:17px;height:17px;flex-shrink:0}.checkbox{display:flex;align-items:start;gap:9px}.checkbox input{margin-top:4px}fieldset{border:0;padding:0;margin:0 0 20px}legend{font-size:13px;font-weight:600;margin-bottom:9px}.day-picker{display:flex;gap:8px;flex-wrap:wrap}.day-picker label{font-size:11px;font-weight:400;border:1px solid var(--line);padding:5px 8px;border-radius:5px;display:flex;align-items:center;gap:4px;margin:0}.records{list-style:none;margin:20px 0 0;padding:0}.records li{border-top:1px solid var(--line);padding:11px 0;display:flex;gap:8px;justify-content:space-between;align-items:center;flex-wrap:wrap;font-size:12px}.review{border-top:1px solid var(--line);padding-top:22px;margin-top:24px}.review h3{margin-top:13px}.review p{font-size:13px}.proposal{border-top:3px solid var(--accent)}.notice,.error{padding:15px 18px;border-radius:7px;margin-bottom:18px;font-size:13px}.notice{background:#eaf3e5}.error{background:#fff0e8;color:#873c18;border:1px solid #e5bea8}.error button{margin-left:10px}.decision-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(245px,1fr));gap:15px}.decision-list article{border:1px solid var(--line);border-radius:7px;padding:18px}dl{display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px}dd{margin:0;overflow-wrap:anywhere}blockquote{margin:16px 0;padding-left:16px;border-left:3px solid var(--line);font-style:italic}footer{margin-top:38px;padding-top:22px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.skip{position:absolute;left:10px;top:-80px;background:white;padding:12px;z-index:10}.skip:focus{top:10px}.case{max-width:1160px;margin:auto;padding:45px 38px}.case .brand{margin-bottom:80px}.case-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:70px;align-items:center}.case h1{font-size:66px;letter-spacing:-2px}.lede{font-size:21px;color:var(--muted);line-height:1.6}.case-grid section>p{max-width:510px}.case .button{margin:14px 0 10px}.case-card{background:var(--mint);padding:35px;border-radius:13px}.case-card ol{list-style:none;padding:0;margin:25px 0 0}.case-card li{display:flex;gap:18px;padding:18px 0;border-bottom:1px solid #ccdbcb}.case-card li:last-child{border:0}.case-card b{font-family:Georgia,serif;color:#6a8c79;font-size:25px}.case-card span{font-size:16px}.case-card small{display:block;font-size:12px;margin-top:5px}.evidence{margin-top:85px}.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;margin-top:26px}.tiles article{border-top:2px solid var(--accent);padding-top:20px}.tiles p{font-size:13px;color:var(--muted)}.tiles a{display:block;font-size:12px;margin:10px 0} @media(max-width:1000px){.shell{grid-template-columns:190px minmax(0,1fr)}.content main{padding:25px}.topbar{padding:16px 25px}.main-grid{grid-template-columns:1fr}.page-title{align-items:start}.case-grid{gap:35px}.case h1{font-size:50px}} @media(max-width:650px){.shell{display:block}.sidebar{padding:15px;border-right:0;border-bottom:1px solid var(--line)}.sidebar .brand{margin-bottom:12px;font-size:24px}.sidebar nav{display:flex;gap:6px}.role{display:block;text-align:center;margin:0;font-size:12px;padding:9px 5px}.role small,.sidebar .eyebrow,.role-icon{display:none}.sidebar-bottom{padding-top:10px;display:flex;gap:12px;align-items:center;margin:0}.sidebar-bottom p{display:none}.sidebar-bottom a{margin-left:auto}.topbar{padding:10px 16px}.toolbar{gap:8px}.content main{padding:23px 16px}.page-title{display:block}.page-title h1{font-size:32px}.scenario{max-width:none}.scenario select{padding:8px}.guide{padding:15px;gap:10px}.steps{gap:10px 14px;justify-content:start}.card{padding:20px}.card-heading{flex-wrap:wrap}.slots{gap:8px}.slots>div{min-width:80px;padding:10px}.case{padding:25px 20px}.case .brand{margin-bottom:45px}.case-grid{grid-template-columns:1fr;gap:25px}.case h1{font-size:48px}.tiles{grid-template-columns:1fr}.evidence{margin-top:50px}.main-grid{grid-template-columns:minmax(0,1fr)}} /* CM-inspired green and open surfaces, with stronger type and lime accents. */ :root{--ink:#213c2a;--muted:#536b59;--accent:#26753c;--line:#d9e6d9;--paper:#f8fbf5;--mint:#e8f3df;--punch:#d9ed69;--grass:#49a347} body{font-family:"Segoe UI",Arial,Helvetica,sans-serif} h1{font-family:"Segoe UI",Arial,sans-serif;font-weight:800;font-size:42px;letter-spacing:-1.6px;line-height:1.1} h2{font-weight:750;letter-spacing:-.5px}h3{font-weight:700} button,.button{border-radius:999px;padding:11px 21px}button:disabled{opacity:1;background:#e6ece3;color:#61725f;border-color:#d7dfd3} .quiet{background:transparent;border-radius:8px;padding:7px 10px}.quiet:disabled{background:transparent} .secondary{border-color:#99ba94;background:#fff}.brand{font-family:"Segoe UI",Arial,sans-serif;font-weight:850;font-size:29px;letter-spacing:-2px;color:var(--accent)} .brand>span{font-family:"Segoe UI",Arial,sans-serif;color:var(--ink);font-weight:750} .sidebar{border-top:6px solid var(--grass);background:white}.role{border-radius:12px;padding:13px 12px}.role.selected{box-shadow:none;background:var(--mint)}.role.selected .role-icon{color:var(--accent)} .topbar{background:white}.guide{background:var(--mint);border:0;border-left:5px solid var(--grass);border-radius:5px 20px 20px 5px;padding:22px}.guide-icon{color:var(--ink);background:var(--punch);width:40px;height:40px;flex-shrink:0;border-radius:50%;text-align:center;line-height:40px}.guide strong{color:var(--accent)} .card{border-radius:18px;box-shadow:0 4px 16px #224b2610}.tinted{background:#edf5e6;border-color:#d3e3cb}.pill{border-radius:6px;background:var(--mint);color:#325738;border-color:#d2e3ca;font-weight:600}.card-heading>.pill{border-radius:999px} .slots>div{background:#f2f7eb;border-radius:12px;border-color:#d7e6cc}.slots strong{font-family:"Segoe UI",Arial,sans-serif;font-size:32px;font-weight:800;letter-spacing:-1px}.steps .reached span{background:var(--punch);border-color:#b9d34c;color:var(--ink);font-weight:800} .proposal{border:2px solid var(--grass);background:#fbfef8}.eyebrow{color:#3b7343}.page-title{margin-bottom:28px}.scenario select{border-radius:10px} .case{max-width:1210px}.case h1{font-family:"Segoe UI",Arial,sans-serif;font-weight:850;font-size:72px;letter-spacing:-3px;line-height:1.02;color:#286c37}.case .lede{color:var(--ink);font-size:22px;line-height:1.45;font-weight:550}.case .button{background:var(--punch);color:var(--ink);border-color:var(--punch);padding:15px 25px;font-size:16px}.case-card{background:var(--mint);border-radius:24px 70px 24px 24px;border:0;border-top:8px solid var(--grass);padding:35px;position:relative}.journey-link{color:var(--ink);text-decoration:none;position:absolute;right:23px;top:-25px;background:var(--punch);border:5px solid var(--paper);border-radius:50%;width:60px;height:60px;display:grid;place-items:center;font-size:28px;font-weight:700}.case-card b{font-family:"Segoe UI",Arial,sans-serif;font-size:28px;font-weight:800;color:var(--accent)}.case-card li{border-color:#c8ddbd}.case-card span{font-weight:650}.case-card small{font-weight:400}.evidence h2{font-size:32px}.tiles article{border-top:4px solid var(--grass);padding:24px 22px;background:white;border-radius:0 0 15px 15px}.tiles article:nth-child(2){border-color:#b8cf35}.tiles a{font-size:13px}.case footer strong{color:var(--ink)} @media(max-width:1000px){.case h1{font-size:57px}.page-title h1{font-size:36px}} @media(max-width:650px){.sidebar{border-top-width:4px}.role{padding:9px 5px}.case h1{font-size:49px;letter-spacing:-2px}.page-title h1{font-size:33px;letter-spacing:-1px}.guide{padding:16px 14px}.guide-icon{width:32px;height:32px;line-height:32px;font-size:22px}.case-card{padding:27px}.card{border-radius:14px}.case .lede{font-size:20px}} .journey-link:hover{filter:brightness(.93)} .sidebar-bottom .overview-link{display:flex;align-items:center;justify-content:center;gap:10px;min-height:46px;width:100%;margin-top:20px;padding:12px 14px;border:2px solid var(--accent);border-radius:12px;background:var(--accent);color:white;text-decoration:none;font-size:14px;font-weight:700;line-height:1.3}.overview-link:hover{filter:brightness(.93)}.overview-link span{font-size:20px;line-height:1}@media(max-width:650px){.sidebar-bottom{flex-wrap:wrap}.sidebar-bottom .overview-link{width:auto;margin-top:0;margin-left:auto;flex-shrink:0}} .toolbar .overview-link{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 14px;border:2px solid var(--accent);border-radius:12px;background:var(--accent);color:white;font-size:14px;font-weight:700;line-height:1.3;text-decoration:none}@media(max-width:650px){.topbar{flex-wrap:wrap;gap:10px}.toolbar{flex-wrap:wrap;gap:8px}.toolbar .overview-link{font-size:13px;padding:8px 10px}} .case-header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:80px}.case .case-header .brand{margin-bottom:0}.about-link{display:inline-flex;align-items:center;gap:7px;min-height:44px;padding:10px 0;font-size:15px;font-weight:600;white-space:nowrap}@media(max-width:650px){.case-header{gap:12px;margin-bottom:45px}.case-header .brand{gap:8px}.case-header .brand>span{font-size:14px}.about-link{font-size:14px}} ===== src/app/layout.tsx ===== import './globals.css'; export default function RootLayout({children}: {children: React.ReactNode}) {return children;} ===== src/app/page.tsx ===== import {redirect} from 'next/navigation'; export default function Home() {redirect('/en');} ===== src/components/demo.tsx ===== 'use client'; import {useCallback, useEffect, useRef, useState} from 'react'; import Link from 'next/link'; import {useLocale, useTranslations} from 'next-intl'; import {Activity, Change, CheckIn, Member, Plan, Review, Role, Support, Version} from '@/domain/model'; type Session = {generation: number; revision: number; role: Role; scenario: string}; type Decision = {id: string; memberRef: string; ruleId: string; ruleVersion: string; outcome: string; planVersion: number; reviewRevision: number; templateId: string; templateVersion: number; inputCategories: {difficulty: string | null; completeness: string; recordedCount: number}}; type View = Session & {member?: Member; plan?: Plan | null; etag?: string; activities?: Activity[]; checkIn?: CheckIn; review?: Review; changes?: Change[]; supportRequests?: Support[]; versions?: Version[]; decisions?: Decision[]}; export default function Demo() { const t = useTranslations(); const locale = useLocale(); const [session, setSession] = useState(null); const sessionRef = useRef(null); const [view, setView] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [goal, setGoal] = useState(''); const [days, setDays] = useState([1,3,6]); const [activityType, setActivityType] = useState('walking'); const [minutes, setMinutes] = useState(10); const [date, setDate] = useState('2026-10-05'); const [duration, setDuration] = useState(10); const [difficulty, setDifficulty] = useState('difficult'); const [complete, setComplete] = useState(true); const [note, setNote] = useState(''); const [rationale, setRationale] = useState(''); const [sharingDraft, setSharingDraft] = useState(null); const [retry, setRetry] = useState<{path: string; method: string; body: unknown; etag?: string; key: string} | null>(null); const remember = useCallback((s: Session) => {sessionRef.current = s; setSession(s);}, []); const read = useCallback(async (s: Session) => { const response = await fetch('/api/v1/demo/state', {cache: 'no-store'}); const data = await response.json(); if (!response.ok) {setView(null); setError(data.code); return;} remember(data); setView(data); if (data.member) {setGoal(data.member.preferences.goal); setDays(data.member.preferences.availableDays); setMinutes(data.member.preferences.minutesPerSlot); setActivityType(data.member.preferences.activityType);} // Keep session controls usable even when protected coach data is unavailable. if (!s) return; }, [remember]); useEffect(() => { let active = true; async function initialize() { try { let response = await fetch('/api/v1/demo/session', {cache:'no-store'}); if (response.status === 401) response = await fetch('/api/v1/demo/start', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'}); const data = await response.json(); if (!response.ok) throw new Error(data.code); if (active) {remember(data); await read(data);} } catch (e) {if (active) setError(e instanceof Error ? e.message : 'service_unavailable');} } void initialize(); return () => {active = false;}; }, [read, remember]); async function mutate(path: string, body: unknown, method = 'POST', etag?: string) { if (busy || !sessionRef.current) return; setBusy(true); setError(''); setNotice(''); const operation = retry && retry.path === path && retry.method === method && JSON.stringify(retry.body) === JSON.stringify(body) && retry.etag === etag ? retry : {path, method, body, etag, key: crypto.randomUUID()}; setRetry(operation); try { const s = sessionRef.current; const response = await fetch(`/api/v1/${path}`, {method, headers: {'Content-Type':'application/json', 'Idempotency-Key':operation.key, 'X-Demo-Generation':String(s.generation), 'X-Demo-Revision':String(s.revision), ...(etag ? {'If-Match':etag} : {})}, body:JSON.stringify(body)}); const data = await response.json(); if (!response.ok) { setRetry(null); if (['stale_actor','stale_generation','etag_mismatch'].includes(data.code)) { const current = await fetch('/api/v1/demo/session'); const ss = await current.json(); if (current.ok) {remember(ss); await read(ss);} } setError(data.code); return; } setRetry(null); if (path.startsWith('demo/')) {remember(data); setNote(''); setRationale(''); await read(data);} else await read(s); setNotice(t(path.includes('changes') && !path.endsWith('decision') ? 'proposalSaved' : path === 'support-requests' ? 'requestSaved' : 'saved')); } catch {setError('network_error');} finally {setBusy(false);} } const p = view?.plan; const share = view?.member?.consents.find(c => c.purpose === 'coaching_share')?.granted ?? false; const pending = view?.changes?.find(c => c.state === 'proposed'); const unresolved = view?.supportRequests?.find(r => ['requested','proposed'].includes(r.state)); const completed = (p?.currentVersion ?? 0) > 1; const step = completed ? 5 : !p ? 0 : p.state === 'proposed' ? 1 : pending ? 5 : unresolved ? 4 : view?.review ? 3 : 2; const guidance = session?.role === 'coach' ? 'coachGuide' : session?.role === 'analyst' ? 'analystGuide' : completed ? 'completedGuide' : !p ? 'preferencesGuide' : p.state === 'proposed' ? 'acceptGuide' : pending ? 'decisionGuide' : unresolved ? 'handoffGuide' : view?.review ? 'supportGuide' : 'recordGuide'; const submit = (event: React.FormEvent, fn: () => void) => {event.preventDefault(); fn();}; return
{t('skip')}
{t('demo')} / {session ? t(session.role) : t('loading')}
{t('backOverview')}{locale === 'en' ? 'Nederlands' : 'English'}

{t('caseStudy')}

{session?.role === 'coach' ? t('coachTitle') : session?.role === 'analyst' ? t('analystTitle') : t('memberTitle')}

{t('subtitle')}

{t('nextStep')}

{t(guidance)}

{notice}
{error &&
{t.has(`errors.${error}`) ? t(`errors.${error}`) : t('errors.service_unavailable')}
}{!session && !error &&

{t('loading')}

} {session?.role === 'member' && view?.member && <>
    {['stepPreferences','stepPlan','stepRecord','stepReview','stepCoach','stepDecision'].map((s,i) =>
  1. = i ? 'reached' : ''}>{i+1}{t(s)}
  2. )}
{!p &&

01 · {t('stepPreferences')}

{t('preferencesTitle')}

submit(e, () => mutate('members/me',{preferences:{goal,activityType,availableDays:days,minutesPerSlot:minutes}},'PATCH'))}>
{t('days')}
{[1,2,3,4,5,6,7].map(day => )}

{t('templateLimit')}

} {p &&

{t('stepPlan')}

{t('yourPlan')}

{t(p.state)} · {t('version')} {p.currentVersion || 1}

{t('matched')}

{p.slots.map(s =>
{t(`day${s.dayOfWeek}`)}{s.durationMinutes} {t('minutes')}{t('walking')}
)}
{t('templateDetails')}

WALK-01 · v1 · {t('demoOnly')}

{t('templateLimit')}

{p.state === 'proposed' &&
}
} {p?.state === 'active' && <>

02 · {t('stepRecord')}

{t('journal')}

submit(e, () => mutate('activities',{occurredOn:date,durationMinutes:duration,activityType:'walking'}))}>
    {view.activities?.map(a =>
  • {a.occurredOn} · {t('walking')}{a.durationMinutes} {t('minutes')}{t('manual')}
  • )}
{!view.activities?.length &&

{t('noRecords')}

}

03 · {t('stepReview')}

{t('checkInTitle')}

submit(e, () => mutate(`plans/${p.id}/check-ins/2026-10-05`,{difficulty,reportingComplete:complete,note,barriers:[]},'PUT'))}>