const PURPOSES = [ { id: "research", title: "Health Research", blurb: "Scientific knowledge.", refs: [] }, { id: "quality", title: "Quality Improvement", blurb: "Improve your own service.", refs: [] }, { id: "statistics", title: "Statistics / Official", blurb: "Org/regional/national stats.", refs: [] }, { id: "innovation", title: "Innovation / AI", blurb: "Tool/algorithm/model.", refs: [] }, { id: "anon", title: "Exclusively Anonymized Data", blurb: "No reasonable reidentification risk.", refs: [] }, ]; const STEPS = [ { num: 1, key: "classification", title: "Project classification", items: [ { k: "1a", label: "Record purpose, controller(s)/processor(s) roles and identifiability.", req: true, refs: [ { title: "HRA §2 (scope)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A72" }, { title: "HRA §4 (definitions)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A74" }, { title: "GDPR Art. 4; Art. 4(7)", url: "https://gdpr-info.eu/art-4-gdpr/" } ], }, { k: "1b", label: "Identify data status: personal / special category (health/genetic/biometric) / anonymized. Pseudonymized data remains personal; genetics falls under biotechnology.", req: true, refs: [ { title: "GDPR Art. 9(1) (special)", url: "https://gdpr-info.eu/art-9-gdpr/" }, { title: "GDPR Recital 26 (anonymized)", url: "https://gdpr-info.eu/recitals/no-26/" }, { title: "GDPR Art. 4(5) (pseudonymized)", url: "https://gdpr-info.eu/art-4-gdpr/" } ], }, { k: "1c", label: "If medical/health research: HRA applies and REK decides scope (before access).", req: true, showFor: ["research"], refs: [ { title: "HRA §2; §4; REK scope", url: "https://lovdata.no/lov/2008-06-20-44" } ], }, { k: "1d", label: "If using health registers: record registry restrictions.", req: false, showFor: ["statistics", "research", "quality", "innovation"], refs: [ { title: "HRL §§8–11 (registers)", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" } ], }, { k: "1e", label: "Consult health trusts data protection officer, retain written rationale.", req: true, showFor: ["research", "quality", "statistics", "innovation"], refs: [ { title: "GDPR Art. 5(2) (accountability)", url: "https://gdpr-info.eu/art-5-gdpr/" }, { title: "GDPR Art. 24(1) (responsibility)", url: "https://gdpr-info.eu/art-24-gdpr/" } ], }, { k: "1f", label: "Plan periodic re-review as tech/law evolve (accountability).", req: false, refs: [{ title: "GDPR Art. 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, ], }, { num: 2, key: "legal", title: "Legal basis & consent", items: [ { k: "2a", label: "Select legal route ;public interest/official authority ; research/statistics with safeguards; healthcare/management/quality improvment", req: true, refs: [ { title: "GDPR Art. 6(1)(e)", url: "https://gdpr-info.eu/art-6-gdpr/" }, { title: "GDPR Art. 9(2)(h)/(j)", url: "https://gdpr-info.eu/art-9-gdpr/" }, { title: "GDPR Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" } ], }, { k: "2b", label: "Data protection impact assessment for high-risk projects; document rationale if not.", req: true, refs: [{ title: "GDPR Art. 35 (DPIA)", url: "https://gdpr-info.eu/art-35-gdpr/" }], }, { k: "2c", label: "Health-law confidentiality satisfied via valid consent or dispensation (archival/research/statistics in public interest).", req: true, refs: [ { title: "HPA §21 (confidentiality)", url: "https://lovdata.no/lov/1999-07-02-64/%C2%A721" }, { title: "HPA §29; HRL §19e (dispensation)", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" } ], }, { k: "2d", label: "If using consent: GDPR-valid; information duties; withdrawal covered (re-consent if needed).", req: false, refs: [ { title: "GDPR Art. 7 (consent)", url: "https://gdpr-info.eu/art-7-gdpr/" }, { title: "GDPR Arts. 13–14 (information)", url: "https://gdpr-info.eu/art-13-gdpr/" }, { title: "HRA §13 (consent, if HRA)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A713" } ], }, ], }, { num: 3, key: "ethics", title: "Ethics & regulatory approvals", items: [ { k: "3a", label: "Regional committees for medical and health research ethics (REK) approval or exemption before research access/use.", req: true, showFor: ["research"], refs: [ { title: "HRA §9; §33", url: "https://lovdata.no/lov/2008-06-20-44" } ], }, { k: "3b", label: "Take into account ethics/regulators (REK), DPA (Datatilsynet), registry/data holders (e.g., linkage constraints).", req: true, refs: [ { title: "Datatilsynet — rules/tools", url: "https://www.datatilsynet.no/regelverk-og-verktoy/lover-og-regler/" } ], }, { k: "3c", label: "RoPA & transparency notices ready.", req: true, refs: [ { title: "GDPR Art. 30 (RoPA)", url: "https://gdpr-info.eu/art-30-gdpr/" }, { title: "GDPR Arts. 13–14 (transparency)", url: "https://gdpr-info.eu/art-13-gdpr/" } ], }, { k: "3d", label: "Include AI details in materials where applicable; plan obligations.", req: true, showFor: ["innovation"], refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }], }, ], }, { num: 4, key: "access", title: "Data access & agreements", items: [ { k: "4a", label: "Obtain data user or data sharing agreement (DUA/DSA or equivalent) with data provider: scope, purpose, duration, security, confidentiality, post-hoc control, return/destruction.", req: true, refs: [ { title: "HPA §21 (confidentiality)", url: "https://lovdata.no/lov/1999-07-02-64/%C2%A721" }, { title: "HPA §29; HRL §19e (dispensation)", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }, { title: "GDPR 28(3)-processor contract)", url: "https://gdpr-info.eu/art-28-gdpr/" }, { title: "GDPR 26- joint controllers", url: "https://gdpr-info.eu/art-26-gdpr/" } ], }, { k: "4b", label: "Security controls for controller/processor incl. access control & logging.", req: true, refs: [ { title: "GDPR Art. 32 (security)", url: "https://gdpr-info.eu/art-32-gdpr/" }, { title: "PRA §22; PRR §14 (logging)", url: "https://lovdata.no/forskrift/2019-03-01-168/%C2%A714" }, { title: "Normen §5.2; §5.4.4", url: "https://www.helsedirektoratet.no/english/the-code-of-conduct-for-information-security-and-data-protection" } ], }, { k: "4c", label: "Team confidentiality/training completed (duty of confidentiality).", req: true, refs: [ { title: "HPA §21", url: "https://lovdata.no/lov/1999-07-02-64/%C2%A721" } ], }, { k: "4d", label: "Use Secure Processing or Secure Analysis Environment(SPE/SAE) (e.g., TSD/HUNT Cloud/SAFE). Transfers via secure channels (logging, access control, encryption).", req: true, refs: [ { title: "GDPR Art. 32", url: "https://gdpr-info.eu/art-32-gdpr/" }, { title: "Normen (logging/encryption)", url: "https://www.helsedirektoratet.no/normen/logging-og-innsyn-i-logg-faktaark-15" } ], }, { k: "4e", label: "Data comes from an external source, obtain data access agreements, processor/joint controller contracts, DSA/DUA signed", req: false, refs: [ { title: "HRL §19e; HPA §29", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }, { title: "GDPR Art. 28(3)", url: "https://gdpr-info.eu/art-28-gdpr/" }, { title: "GDPR Art. 26", url: "https://gdpr-info.eu/art-26-gdpr/" } ], }, // { // k: "4e", // label: // "Data comes from an external source (outside institutions: registries/health trusts).", // req: false, // children: [ // { // k: "4e-1", // label: // "Obtain data access agreements; dispensation where applicable.", // req: false, // refs: [ // { title: "HRL §19e; HPA §29", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" } // ], // }, // { // k: "4e-2", // label: // "Processor/joint controller contracts (GDPR 28(3), 26) and DSA/DUA signed (scope, duration, security, destruction/return).", // req: false, // refs: [ // { title: "GDPR Art. 28(3)", url: "https://gdpr-info.eu/art-28-gdpr/" }, // { title: "GDPR Art. 26", url: "https://gdpr-info.eu/art-26-gdpr/" } // ], // }, // ], // }, { k: "4f", label: "Maximum permitted retention/access period set and followed.", req: true, refs: [{ title: "HRL §19f", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }], }, { k: "4g", label: "Agreement aligns with SPE checklists.", req: true, }, { k: "4h", label: "European health data space (EHDS) alignment for cross-border/secondary use (permit; SPE; permitted purposes).", req: false, refs: [ { title: "EHDS Art. 68 (permit); Arts. 73/75 (SPE); Art. 53 (purposes)", url: "https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=OJ:L_202500327" } ], }, ], }, { num: 5, key: "security", title: "Data security & privacy", items: [ { k: "5a", label: "Pseudonymize where possible; keep keys separate.", req: true, refs: [{ title: "GDPR Art. 4(5)", url: "https://gdpr-info.eu/art-4-gdpr/" }], }, { k: "5b", label: "Encrypted storage in transit/at rest, Role-Based access control, multi-factor authentication, network segregation, key mgmt, logging with risk-based review.", req: true, refs: [ { title: "GDPR Art. 32", url: "https://gdpr-info.eu/art-32-gdpr/" }, { title: "PRA §22; PRR §14; Normen factsheets", url: "https://www.helsedirektoratet.no/normen/logging-og-innsyn-i-logg-faktaark-15" } ], }, { k: "5c", label: "Derived datasets with personal data protected equally.", req: true, refs: [{ title: "GDPR Art. 5(1)(f); Art. 32", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, { k: "5d", label: "Data Protection Impact Assessment (DPIA) done before analysis (large-scale health/innovative AI/vulnerable groups) or rationale recorded.", req: true, refs: [{ title: "GDPR Art. 35", url: "https://gdpr-info.eu/art-35-gdpr/" }], }, { k: "5e", label: "Consult Data Protection Officer (DPO) as required; record advice and implement recommendations.", req: false, refs: [{ title: "GDPR Arts. 37–39", url: "https://gdpr-info.eu/chapter-4/" }], }, { k: "5f", label: "Sharing within NO/EU: verify permission, lawful basis/principles; respect IP/licensing/REK terms.", req: false, refs: [ { title: "GDPR Art. 5(1)(b)-(c); Art. 6(1)", url: "https://gdpr-info.eu/art-5-gdpr/" }, { title: "GDPR Arts. 44–49 (transfers)", url: "https://gdpr-info.eu/chapter-5/" }, { title: "HRA §33 (conditions)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A733" } ], }, { k: "5g", label: "Transfers outside EEA: lawful mechanism (adequacy/Standard Contractual Clauses/derogations); Transfer Impact Assessment (TIA) documented; approvals/logs kept.", req: true, refs: [ { title: "GDPR Arts. 44–46", url: "https://gdpr-info.eu/chapter-5/" }, { title: "GDPR Art. 49", url: "https://gdpr-info.eu/art-49-gdpr/" } ], }, { k: "5h", label: "Third-party tools/vendors: GDPR 28 DPAs; safeguards vetted.", req: true, refs: [{ title: "GDPR Art. 28", url: "https://gdpr-info.eu/art-28-gdpr/" }], }, { k: "5i", label: "Data breach: notify national data protection authority (Datatilsynet) within 72h where required; assess duty to inform subjects.", req: true, refs: [{ title: "GDPR Arts. 33–34", url: "https://gdpr-info.eu/art-33-gdpr/" }], }, { k: "5j", label: "Periodic review of security (incl. SPE/SAE).", req: true }, ], }, { num: 6, key: "quality", title: "Data minimisation & quality", items: [ { k: "6a", label: "Identify & extract only necessary/approved fields (minimisation & purpose limitation).", req: true, refs: [{ title: "GDPR Art. 5(1)(b)-(c)", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, { k: "6b", label: "Post-extraction sweeps for unexpected/disallowed content (free text/images) unless approved.", req: true, refs: [{ title: "GDPR Art. 5(1)(c); Art. 25", url: "https://gdpr-info.eu/art-25-gdpr/" }], }, { k: "6c", label: "Conform to approvals, scope & objectives.", req: true, }, { k: "6d", label: "Periodic data quality review aligned with emerging standards/regulatory updates (accuracy/accountability).", req: true, refs: [{ title: "GDPR Art. 5(1)(d); 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, ], }, { num: 7, key: "analysis", title: "Analysis & AI development", items: [ { k: "7a", label: "Analyse strictly within SPE/SAE; validated tools; no re-identification unless legally authorised.", req: true, refs: [{ title: "GDPR Art. 5(1)(a)-(b)", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, { k: "7b", label: "Maintain comprehensive preprocessing documentation (cleaning/transforms/anonymity) & technical documentation.", req: true, refs: [ { title: "AI Act Art. 10; Annex XI–XII", url: "https://ai-act-law.eu/article/10/" }, { title: "GDPR Art. 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" } ], }, { k: "7c", label: "Perform an AI risk classification assessment; document (classification framework, Annex III categories).", req: true, showFor: ["research","innovation"], refs: [ { title: "AI Act Arts. 6–7; Annex III", url: "https://ai-act-law.eu/article/6/" } ], children: [ { k: "7c-1", label: "High-risk AI: implement risk mgmt & controls; define human oversight; validate performance; manage drift; ensure cybersecurity.", req: true, refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }], }, { k: "7c-2", label: "High-risk AI: data governance for train/val/test — representative, relevant, accurate; bias assessed.", req: true, refs: [{ title: "AI Act Art. 10(2)", url: "https://ai-act-law.eu/article/10/" }], }, // { // k: "7c-3", // label: // "Non-high-risk AI: adopt good practice on data quality, bias (subgroup eval/mitigation/monitoring), transparency & human oversight; consider voluntary codes.", // req: false, // refs: [{ title: "AI Act Arts. 95–96", url: "https://ai-act-law.eu/article/95/" }], // }, // { // k: "7c-4", // label: // "Maintain technical documentation & record-keeping (model, algorithms, data sources, intended purpose).", // req: true, // refs: [ // { title: "AI Act Art. 11 (tech docs)", url: "https://ai-act-law.eu/article/11/" }, // { title: "AI Act Art. 12 (records)", url: "https://ai-act-law.eu/article/12/" } // ], // }, // { // k: "7c-5", // label: // "For AI projects: ongoing obligations — risk mgmt, data governance, docs, record-keeping, transparency, human oversight, accuracy & cybersecurity.", // req: true, // refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }], // }, // { // k: "7c-6", // label: // "If developing an AI model: verify permission & GDPR legal basis; remove personal data or ensure vetted/validated use; respect IP/licensing & REK terms.", // req: true, // refs: [ // { title: "AI Act Art. 2(7) (scope)", url: "https://ai-act-law.eu/article/2/" }, // { title: "GDPR Art. 5(1)(b)-(c); Art. 6(1); Arts. 44–49", url: "https://gdpr-info.eu/art-6-gdpr/" } // ], // }, // { // k: "7c-7", // label: // "If deploying on EU market: conformity/CE/registration in EU AI database where required.", // req: false, // refs: [{ title: "AI Act Arts. 30 & 43", url: "https://ai-act-law.eu/article/30/" }], // }, // { // k: "7c-8", // label: // "Risk mgmt & bias mitigation documented for interpretation of results.", // req: true, // refs: [ // { title: "AI Act Art. 9; Art. 10(2)(e)", url: "https://ai-act-law.eu/article/9/" }, // { title: "GDPR Art. 35 (DPIA)", url: "https://gdpr-info.eu/art-35-gdpr/" }, // ], // }, // { // k: "7c-9", // label: // "Exclude malpractice data; run fairness checks (e.g., demographic parity, equalised odds, subgroup analysis); schedule bias audits & compliance reviews; handle subject rights.", // req: true, // refs: [ // { title: "AI Act Arts. 9, 10(2)(e), 15", url: "https://ai-act-law.eu/article/9/" }, // { title: "GDPR Arts. 12–22 (rights)", url: "https://gdpr-info.eu/chapter-3/" } // ], // }, // { // k: "7c-10", // label: // "Plan periodic reassessment & review as tech/regulations evolve.", // req: true, // }, ], }, { k: "7d", label: "Non-high-risk AI: adopt good practice on data quality, bias (subgroup eval/mitigation/monitoring), transparency & human oversight; consider voluntary codes.", req: false, showFor: ["research", "quality","innovation"], refs: [{ title: "AI Act Arts. 95–96", url: "https://ai-act-law.eu/article/95/" }], }, { k: "7e", label: "Maintain technical documentation & record-keeping (model, algorithms, data sources, intended purpose).", req: true, showFor: ["research", "quality","innovation"], refs: [ { title: "AI Act Art. 11 (tech docs)", url: "https://ai-act-law.eu/article/11/" }, { title: "AI Act Art. 12 (records)", url: "https://ai-act-law.eu/article/12/" } ], }, { k: "7f", label: "For AI projects: ongoing obligations — risk mgmt, data governance, docs, record-keeping, transparency, human oversight, accuracy & cybersecurity.", req: false, showFor: ["research", "quality","innovation"], refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }], }, { k: "7g", label: "If developing an AI model: verify permission & GDPR legal basis; remove personal data or ensure vetted/validated use; respect intelectual propety/licensing & REK terms.", req: true, showFor: ["research", "quality","innovation"], refs: [ { title: "AI Act Art. 2(7) (scope)", url: "https://ai-act-law.eu/article/2/" }, { title: "GDPR Art. 5(1)(b)-(c); Art. 6(1); Arts. 44–49", url: "https://gdpr-info.eu/art-6-gdpr/" } ], }, { k: "7h", label: "If deploying on EU market: conformity/CE marking/registration in EU AI database where required.", req: false, showFor: ["research", "quality","innovation"], refs: [{ title: "AI Act Arts. 30 & 43", url: "https://ai-act-law.eu/article/30/" }], }, { k: "7i", label: "Risk mgmt & bias mitigation documented for interpretation of results.", req: false, showFor: ["research", "quality","innovation"], refs: [ { title: "AI Act Art. 9; Art. 10(2)(e)", url: "https://ai-act-law.eu/article/9/" }, { title: "GDPR Art. 35 (DPIA)", url: "https://gdpr-info.eu/art-35-gdpr/" }, ], }, { k: "7j", label: "Exclude malpractice data; run fairness checks (e.g., demographic parity, equalised odds, subgroup analysis); schedule bias audits & compliance reviews; handle subject rights.", req: false, showFor: ["research", "quality","innovation"], refs: [ { title: "AI Act Arts. 9, 10(2)(e), 15", url: "https://ai-act-law.eu/article/9/" }, { title: "GDPR Arts. 12–22 (rights)", url: "https://gdpr-info.eu/chapter-3/" } ], }, { k: "7k", label: "Plan periodic reassessment & review as tech/regulations evolve.", req: true, showFor: ["research", "quality","innovation"], } ], }, { num: 8, key: "compliance", title: "Compliance monitoring & auditing", items: [ { k: "8a", label: "Internal audit (e.g., by/with health trust); remediate discrepancies.", req: true, refs: [{ title: "GDPR Art. 5(2); Art. 24", url: "https://gdpr-info.eu/art-24-gdpr/" }], }, { k: "8b", label: "Maintain a compliance report (docs/approvals/permits/agreements).", req: true }, { k: "8c", label: "All source-system extractions logged (who/when/what); periodic risk-based log review.", req: true, refs: [{ title: "PRA §22; PRR §14; Normen 5.4.4", url: "https://lovdata.no/forskrift/2019-03-01-168/%C2%A714" }], }, { k: "8d", label: "Use only for approved protocol; seek amendments before new purposes or analyses.", req: true, refs: [{ title: "GDPR Art. 5(1)(b); HRA §33", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, { k: "8e", label: "Review & update audit/compliance process periodically (law & technology changes).", req: true, }, ], }, { num: 9, key: "closeout", title: "Dissemination, close-out, retention & deletion", items: [ { k: "9a", label: "Before releasing results, ensure no individual can be identified in outputs.", req: true, refs: [{ title: "GDPR Art. 5(1)(c); Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" }], }, { k: "9b", label: "Publications/reports include ethics & data-protection statements (e.g., REK ref., GDPR compliance; Helsinki where applicable).", req: false, }, { k: "9c", label: "If an AI product/service: disclose intended use, limitations, validation status; transparency.", req: false, refs: [{ title: "AI Act Art. 13", url: "https://ai-act-law.eu/article/13/" }], }, { k: "9d", label: "Fulfil reporting obligations (REK/registries/funders; EU AI database if required).", req: false, refs: [{ title: "AI Act Chapter III", url: "https://ai-act-law.eu/chapter/3/" }], }, { k: "9e", label: "Post-project closure actions (expand when ready).", req: false, children: [ { k: "9e-1", label: "Delete/anonymize all personal data at retention end; double-check backups & stray copies; document date & method.", req: true, refs: [{ title: "GDPR Art. 5(1)(e); 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }], }, { k: "9e-2", label: "Archive key documentation securely (approvals, consent, scripts, final reports, DPIA), typically 5–10 years without identifiable raw data.", req: true, refs: [{ title: "GDPR Art. 5(1)(e); Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" }], }, { k: "9e-3", label: "If retaining/reusing data: record new legal basis (renewed consent, extended REK, or lawful data bank/registry/biobank).", req: true, refs: [ { title: "GDPR Art. 6(1); Art. 9(2)", url: "https://gdpr-info.eu/art-6-gdpr/" }, { title: "HRA §§13–14; §33", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A713" }, { title: "HRL rules", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" } ], }, { k: "9e-4", label: "Final debrief meeting; note best practices and recommendations.", req: false, }, { k: "9e-5", label: "Notify relevant bodies (e.g., REK) that the study is concluded and data destroyed; update RoPA to mark project finished.", req: true, refs: [{ title: "GDPR Art. 30 (RoPA)", url: "https://gdpr-info.eu/art-30-gdpr/" }], }, { k: "9e-6", label: "If an AI model continues in clinical use: govern under ops protocols (monitoring/maintenance/fallback); if discontinued: purge model artifacts to ensure no personal data persists.", req: false, refs: [{ title: "AI Act Arts. 9–15; 30; 43", url: "https://ai-act-law.eu/article/9/" }], }, ], }, ], }, ]; /* Flow questions*/ const FLOW = { 1: { q: "Project classification complete & documented?", onYes: 2, onNo: 1 }, 2: { q: "Valid legal route and consent completed?", onYes: 3, onNo: 2 }, 3: { q: "All ethical and regulatory approvals in place?", onYes: 4, onNo: 3 }, 4: { q: "Data access route & contracts are ok?", onYes: 5, onNo: 4 }, 5: { q: "Information security & privacy measures and risks controlled?", onYes: 6, onNo: 5, }, 6: { q: "Extract matches necessary data approvals?", onYes: 7, onNo: 6 }, /* Step 7 has several conditional questions */ 7: [ { q: "Any new additional purpose ?", yes: 1, no: "next" }, { q: "Any new approvals needed?", yes: 3, no: "next" }, { q: "Any new access/contracts?", yes: 4, no: 8 }, ], 8: { q: "Any gaps found?", onYes: 4, onNo: 9 }, 9: { q: "Will you retain or reuse data beyond approved plan?", onYes: 1, onNo: "end" }, }; /* ------------ State ------------ */ const STORAGE = "suhr_flow_v2_nested"; const $ = (s) => document.querySelector(s); const $$ = (s) => Array.from(document.querySelectorAll(s)); const state = { purpose: null, checks: {}, savedAt: null, visible: 0, step: 0, subQ: 0, reachedEnd: false, }; /* ------------ Purpose UI ------------ */ function renderPurposes() { const host = $("#purposeList"); host.innerHTML = ""; PURPOSES.forEach((p) => { const el = document.createElement("label"); el.className = "option"; el.innerHTML = `
${p.title}
${p.blurb}
${p.id === "anon" ? "No GDPR" : "GDPR"}
`; el.addEventListener("click", (e) => { host.querySelectorAll(".option").forEach(opt => opt.classList.remove("active")); el.classList.add("active"); host.querySelectorAll('input[name="purpose"]').forEach(inp => { inp.checked = (inp.value === p.id); }); state.purpose = p.id; save(true); $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon"); if (p.id === "anon") { hideFlow(); renderDots(0); $("#timeline").innerHTML = ""; $("#finish").classList.remove("show"); state.reachedEnd = false; updateProgress(); return; } state.visible = Math.max(1, state.visible || 1); state.step = 1; state.subQ = 0; state.reachedEnd = false; save(true); $("#finish").classList.remove("show"); renderDots(state.visible); renderTimeline(); showFlow(); askCurrent(); }); host.appendChild(el); }); if (state.purpose) { $$('input[name="purpose"]').forEach((i) => { const isMatch = i.value === state.purpose; i.checked = isMatch; if (isMatch) i.closest(".option").classList.add("active"); }); $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon"); } } /* ------------ Timeline (accordion) ------------ */ function renderTimeline() { const host = $("#timeline"); host.innerHTML = ""; if (!state.purpose || state.purpose === "anon") return; const v = state.visible || 1; for (let i = 0; i < v; i++) addStep(host, STEPS[i], i + 1); expandDetailsForStep(state.step); markCurrentStep(state.step); requestAnimationFrame(drawLinkToFlow); } function addStep(host, step, idx) { const done = isStepComplete(step); const sec = document.createElement("section"); sec.className = "step" + (done ? " done" : ""); sec.dataset.step = step.key; sec.innerHTML = `
${step.num}
${step.title}
${done ? "Complete ✓" : "In progress"}
`; const ul = sec.querySelector(".list"); step.items.forEach((it) => { if (it.showFor && !it.showFor.includes(state.purpose)) return; const checked = !!state.checks[step.key]?.[it.k]; const li = document.createElement("li"); li.className = "li" + (it.children && it.children.length ? " has-children" : ""); const id = `${step.key}_${it.k}`; const refsHtml = it.refs && it.refs.length ? ` ` + it.refs .map( (r) => `${(r.title || r.url)}` ) .join(" • ") + `` : ""; li.innerHTML = `
${it.k}
`; li.addEventListener("click", (e) => { if (e.target.closest("a, button, input, .toggle")) return; if (e.target.closest(".sublist")) return; if (e.target.closest("label")) e.preventDefault(); const cb = li.querySelector(".chk"); cb.checked = !cb.checked; cb.dispatchEvent(new Event("change", { bubbles: true })); }); let sublist; if (it.children && it.children.length) { sublist = document.createElement("ul"); sublist.className = "sublist"; sublist.style.display = checked ? "block" : "none"; it.children.forEach((ch) => { if (ch.showFor && !ch.showFor.includes(state.purpose)) return; const cChecked = !!state.checks[step.key]?.[ch.k]; const cli = document.createElement("li"); cli.className = "li subitem"; const cid = `${step.key}_${ch.k}`; const cRefsHtml = ch.refs && ch.refs.length ? ` ` + ch.refs .map( (r) => `${(r.title || r.url)}` ) .join(" • ") + `` : ""; cli.innerHTML = `
${ch.k}
`; cli.addEventListener("click", (e) => { if (e.target.closest("a, button, input, .toggle")) { e.stopPropagation(); return; } if (e.target.closest("label")) e.preventDefault(); const ccb = cli.querySelector(".chk"); ccb.checked = !ccb.checked; ccb.dispatchEvent(new Event("change", { bubbles: true })); e.stopPropagation(); }); cli.querySelector(".chk").addEventListener("change", (e) => { (state.checks[step.key] ??= {})[ch.k] = e.target.checked; save(true); updateStepBadgeAndProgress(step, sec, idx); }); sublist.appendChild(cli); }); li.appendChild(sublist); } li.querySelector(".chk").addEventListener("change", (e) => { (state.checks[step.key] ??= {})[it.k] = e.target.checked; if (sublist) { sublist.style.display = e.target.checked ? "block" : "none"; } save(true); updateStepBadgeAndProgress(step, sec, idx); }); ul.appendChild(li); }); const detailsBtn = sec.querySelector(".toggle"); detailsBtn.addEventListener("click", () => { const pane = document.getElementById("fold_" + step.key); const nowFold = pane.classList.toggle("fold"); detailsBtn.setAttribute("aria-expanded", (!nowFold).toString()); }); host.appendChild(sec); } function updateStepBadgeAndProgress(step, sec, idx) { const nowDone = isStepComplete(step); $("#badge_" + step.key).textContent = nowDone ? "Complete ✓" : "In progress"; if (nowDone && !sec.classList.contains("done")) { sec.classList.add("done"); /*if (idx === 1) advanceToStep(2); */ } else if (!nowDone) { sec.classList.remove("done"); } renderDots(state.visible); updateProgress(); if ($("#finish").classList.contains("show")) { refreshFinishBanner(); } else if (state.reachedEnd && allStepsComplete()) { finish(); } } function expandDetailsForStep(stepNum) { if (!stepNum) return; $$("#timeline .body").forEach((pane) => pane.classList.add("fold")); const current = STEPS[stepNum - 1]; if (!current) return; const pane = document.getElementById("fold_" + current.key); if (pane) { pane.classList.remove("fold"); const btn = document.querySelector(`button.toggle[data-fold="${current.key}"]`); if (btn) btn.setAttribute("aria-expanded", "true"); } // highlight and link markCurrentStep(stepNum); // Wait for layout to settle then draw the link requestAnimationFrame(drawLinkToFlow); } /* ------------ Dots / progress ------------ */ function renderDots(visible) { const d = $("#dots"); d.innerHTML = ""; for (let i = 1; i <= STEPS.length; i++) { const stepObj = STEPS[i - 1]; const stepDone = isStepComplete(stepObj); const dot = document.createElement("div"); dot.className = "dot" + (i <= visible ? " unlocked" : "") + (i === state.step ? " active" : "") + (stepDone ? " done" : ""); dot.textContent = i; dot.setAttribute("aria-label", `Step ${i}${stepDone ? " complete" : ""}`); if (i <= visible) { dot.addEventListener("click", () => { state.step = i; state.subQ = 0; save(true); askCurrent(); scrollToStep(i); highlightDot(); requestAnimationFrame(drawLinkToFlow); }); } d.appendChild(dot); } updateProgress(); } function highlightDot() { $$("#dots .dot").forEach((el, idx) => { el.classList.toggle("active", idx + 1 === state.step); }); } function scrollToStep(n) { const el = document.querySelector(`.step:nth-of-type(${n})`); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "start" }); el.animate( [ { outlineColor: "#56b4e9", outlineWidth: "0px" }, { outlineColor: "#56b4e9", outlineWidth: "6px" }, { outlineColor: "transparent", outlineWidth: "0px" }, ], { duration: 800 } ); } /* ------------ Flow controller (Yes/No) ------------ */ const flow = $("#flow"), qtxt = $("#qtxt"), yesBtn = $("#yesBtn"), noBtn = $("#noBtn"); function showFlow() { flow.classList.remove("hidden"); } function hideFlow() { flow.classList.add("hidden"); } function askCurrent() { $("#finish").classList.remove("show"); if (state.purpose === "anon" || !state.step) { hideFlow(); return; } expandDetailsForStep(state.step); markCurrentStep(state.step); requestAnimationFrame(drawLinkToFlow); if (state.step === 7) { const seq = FLOW[7]; let idx = state.subQ; if (idx >= seq.length) { state.step = 8; state.subQ = 0; renderDots(state.visible); askCurrent(); return; } const node = seq[idx]; qtxt.textContent = node.q; yesBtn.onclick = () => { if (node.yes === "next") { state.subQ++; askCurrent(); return; } goto(node.yes); }; noBtn.onclick = () => { if (node.no === "next") { state.subQ++; askCurrent(); return; } goto(node.no); }; return; } const node = FLOW[state.step]; if (!node) { hideFlow(); return; } qtxt.textContent = node.q; yesBtn.onclick = () => goto(node.onYes); noBtn.onclick = () => goto(node.onNo); } function goto(dest) { if (dest === "end") { finish(); return; } state.step = dest; state.subQ = 0; if (state.step > state.visible) state.visible = state.step; renderDots(state.visible); renderTimeline(); askCurrent(); scrollToStep(state.step); save(true); requestAnimationFrame(drawLinkToFlow); } /* Called when step 1 completes to auto reveal step 2 */ function advanceToStep(n) { if (state.visible < n) { state.visible = n; renderDots(state.visible); renderTimeline(); scrollToStep(n); } state.step = n; state.subQ = 0; askCurrent(); save(true); } /* ------------ Helpers ------------ */ function itemVisibleForPurpose(it) { return !(it.showFor && !it.showFor.includes(state.purpose)); } function isStepComplete(step) { const d = state.checks[step.key] || {}; const checkItem = (it, parentChecked = true) => { if (!itemVisibleForPurpose(it)) return true; if (!parentChecked) return true; if (it.req && !d[it.k]) return false; if (it.children && d[it.k]) { for (const ch of it.children) { if (!itemVisibleForPurpose(ch)) continue; if (ch.req && !d[ch.k]) return false; } } return true; }; for (const it of step.items) { if (!checkItem(it, true)) return false; } return true; } function allStepsComplete() { if (!state.purpose || state.purpose === "anon") return false; return STEPS.every((s) => isStepComplete(s)); } function updateProgress() { let total = 0, done = 0; STEPS.forEach((s) => { if (state.purpose === "anon" && s.num > 1) return; const d = state.checks[s.key] || {}; s.items.forEach((it) => { if (!itemVisibleForPurpose(it)) return; const countChild = (ch, parentChecked) => { if (!itemVisibleForPurpose(ch) || !parentChecked) return; if (ch.req) { total++; if (d[ch.k]) done++; } }; if (it.req) { total++; if (d[it.k]) done++; } if (it.children && d[it.k]) { it.children.forEach((ch) => countChild(ch, true)); } }); }); const pct = total ? Math.round((100 * done) / total) : 0; $("#pbar").style.width = pct + "%"; } /* ------------ Finish ------------ */ function finish() { hideFlow(); state.reachedEnd = true; save(true); const ok = allStepsComplete(); const titleEl = $("#finishTitle"); const subEl = $("#finishSub"); const confettiHost = $("#confetti"); if (ok) { titleEl.textContent = "End 🎉"; subEl.textContent = "All flow checks passed."; confettiHost.innerHTML = ""; confetti(24); } else { titleEl.textContent = "End"; subEl.textContent = ""; confettiHost.innerHTML = ""; } $("#finish").classList.add("show"); } function refreshFinishBanner() { const ok = allStepsComplete(); const titleEl = $("#finishTitle"); const subEl = $("#finishSub"); const confettiHost = $("#confetti"); if (ok) { titleEl.textContent = "End 🎉"; subEl.textContent = "All flow checks passed."; confettiHost.innerHTML = ""; confetti(24); } else { titleEl.textContent = "End"; subEl.textContent = ""; confettiHost.innerHTML = ""; } } function confetti(n) { const host = $("#confetti"); host.innerHTML = ""; for (let i = 0; i < n; i++) { const piece = document.createElement("i"); piece.style.setProperty("--dx", Math.random() * 300 - 150 + "px"); piece.style.left = 20 + Math.random() * 60 + "%"; piece.style.background = i % 2 ? "#0072B2" : "#E69F00"; piece.style.animationDelay = Math.random() * 0.4 + "s"; host.appendChild(piece); } } /* ------------ Save/Load ------------ */ function save(quiet = false) { state.savedAt = Date.now(); localStorage.setItem(STORAGE, JSON.stringify(state)); if (!quiet) updateProgress(); } function load() { try { const raw = localStorage.getItem(STORAGE); if (raw) Object.assign(state, JSON.parse(raw)); } catch (e) {} } /* ------------ Clear / Reset ------------ */ function clearAll() { try { localStorage.removeItem(STORAGE); } catch (e) {} state.purpose = null; state.checks = {}; state.savedAt = null; state.visible = 0; state.step = 0; state.subQ = 0; state.reachedEnd = false; renderPurposes(); renderDots(0); $("#timeline").innerHTML = ""; $("#finish").classList.remove("show"); $("#anonMsg").classList.add("hidden"); hideFlow(); updateProgress(); } /* ------------ (a) mark the current step and (b) draw the arrow ------------ */ function markCurrentStep(stepNum) { // Clear old $$("#timeline .step").forEach(el => el.classList.remove("current")); const current = document.querySelector(`.step:nth-of-type(${stepNum})`); if (current) current.classList.add("current"); } function drawLinkToFlow() { const flowEl = $("#flow"); const svg = $("#linkSvg"); const path = $("#linkPath"); // Hide arrow if flow is hidden or no current step const current = document.querySelector(`.step:nth-of-type(${state.step})`); const flowHidden = flowEl.classList.contains("hidden"); if (!current || flowHidden) { if (path) path.setAttribute("d", ""); return; } // Dimensions const sRect = current.getBoundingClientRect(); const fRect = flowEl.getBoundingClientRect(); // Start: bottom-center of current step card const x1 = sRect.left + sRect.width / 2; const y1 = sRect.bottom + 4; // a tiny gap below the step // End: top-center of the flow bar const x2 = fRect.left + fRect.width / 2; const y2 = fRect.top - 6; // a tiny gap above the bar // Control points for a smooth bent curve const midY = (y1 + y2) / 2; const c1x = x1; const c1y = midY; const c2x = x2; const c2y = midY; // Resize SVG viewBox to viewport and draw svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); path.setAttribute("d", `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`); // Optional: animate the path slightly on change (simple stroke-dash trick) path.style.transition = "d 0.2s ease"; } /* ------------ Boot ------------ */ // Keep arrow aligned on resize/scroll window.addEventListener("resize", drawLinkToFlow, { passive: true }); window.addEventListener("scroll", drawLinkToFlow, { passive: true }); load(); renderPurposes(); if (state.purpose && state.purpose !== "anon") { state.visible = state.visible || 1; state.step = state.step || 1; renderDots(state.visible); renderTimeline(); showFlow(); askCurrent(); } else { hideFlow(); } updateProgress(); // Clear button handler document.getElementById("clearBtn").addEventListener("click", () => { const ok = confirm("Clear all saved progress and do a fresh start? This will remove your selections!"); if (ok) clearAll(); });