pantdipendra commited on
Commit
ab477c5
Β·
verified Β·
1 Parent(s): 1652feb
Files changed (1) hide show
  1. app.js +1158 -659
app.js CHANGED
@@ -1,698 +1,1197 @@
1
 
2
- /* ------------ Data ------------ */
3
- const PURPOSES = [
4
- {
5
- id: "research",
6
- title: "Health Research (HRA)",
7
- blurb: "Scientific knowledge.",
8
- refs: [],
9
- },
10
- {
11
- id: "quality",
12
- title: "Quality Improvement",
13
- blurb: "Improve your own service.",
14
- refs: [],
15
- },
16
- {
17
- id: "statistics",
18
- title: "Statistics / Official",
19
- blurb: "Org/regional/national stats.",
20
- refs: [],
21
- },
22
- {
23
- id: "innovation",
24
- title: "Innovation / AI",
25
- blurb: "Tool/algorithm/model.",
26
- refs: [],
27
- },
28
- {
29
- id: "anon",
30
- title: "Exclusively Anonymized Data",
31
- blurb: "No reasonable reidentification risk.",
32
- refs: [],
33
- },
34
- ];
35
-
36
- const STEPS = [
37
- {
38
- num: 1,
39
- key: "classification",
40
- title: "Project classification",
41
- items: [
42
- { k: "1a", label: "(a) Confirm pathway.", req: true },
43
- {
44
- k: "1b",
45
- label: "(b) Controller / joint controller β€” Art. 4(7).",
46
- req: true,
47
- },
48
- { k: "1c", label: "(c) 1-page summary.", req: true },
49
- { k: "1d", label: "(d) Special categories? β€” Art. 9.", req: true },
50
- { k: "1e", label: "(e) Vulnerable groups screened.", req: true },
51
- {
52
- k: "1f",
53
- label: "(f) DPIA screening / DPIA β€” Art. 35.",
54
- req: true,
55
- },
56
- { k: "1g", label: "(g) Transfers (if any) β€” Chap. V.", req: false },
57
- {
58
- k: "1h",
59
- label: "(h) Plan anonymization/pseudonymization β€” Recital 26.",
60
- req: false,
61
- },
62
- ],
63
- },
64
- {
65
- num: 2,
66
- key: "legal",
67
- title: "Legal basis & consent",
68
- items: [
69
- { k: "2a", label: "(a) Art. 6 lawful basis.", req: true },
70
- { k: "2b", label: "(b) Art. 9(2) condition.", req: true },
71
- {
72
- k: "2d",
73
- label: "(d) Purpose limitation β€” Art. 5(1)(b).",
74
- req: true,
75
- },
76
- {
77
- k: "2c",
78
- label: "(c) Consent quality/withdrawal (if used).",
79
- req: false,
80
- },
81
- {
82
- k: "2e",
83
- label: "(e) Research/statistics safeguards β€” Art. 89.",
84
- req: false,
85
- },
86
- ],
87
- },
88
- {
89
- num: 3,
90
- key: "ethics",
91
- title: "Approvals",
92
- items: [
93
- {
94
- k: "3a",
95
- label: "(a) Research: IRB/HRA Β§Β§2–4, Β§9, Β§33.",
96
- req: true,
97
- showFor: ["research"],
98
- },
99
- {
100
- k: "3b",
101
- label: "(b) Statistics: HRL Β§19–19h.",
102
- req: true,
103
- showFor: ["statistics"],
104
- },
105
- {
106
- k: "3c",
107
- label: "(c) QI/Other: internal governance.",
108
- req: true,
109
- showFor: ["quality", "other"],
110
- },
111
- {
112
- k: "3d",
113
- label: "(d) Innovation/AI: risk review / AI Act.",
114
- req: true,
115
- showFor: ["innovation"],
116
- },
117
- {
118
- k: "3e",
119
- label: "(e) RoPA + notices β€” Arts. 30, 13/14.",
120
- req: true,
121
- },
122
- ],
123
- },
124
- {
125
- num: 4,
126
- key: "access",
127
- title: "Data access & contracts",
128
- items: [
129
- { k: "4a", label: "(a) Access route confirmed.", req: true },
130
- { k: "4b", label: "(b) DPA / sharing / DUA ready.", req: true },
131
- { k: "4c", label: "(c) Minimize data β€” Art. 5(1)(c).", req: true },
132
- { k: "4d", label: "(d) Transfers (if any) β€” Chap. V.", req: false },
133
- ],
134
- },
135
- {
136
- num: 5,
137
- key: "security",
138
- title: "Security & privacy",
139
- items: [
140
- { k: "5a", label: "(a) TOMs β€” Art. 32.", req: true },
141
- { k: "5b", label: "(b) Pseudonymize; separate keys.", req: true },
142
- { k: "5c", label: "(c) DPIA outcomes signed.", req: true },
143
- { k: "5d", label: "(d) Breach plan β€” 33/34.", req: true },
144
- ],
145
- },
146
- {
147
- num: 6,
148
- key: "quality",
149
- title: "Data minimization and quality",
150
- items: [
151
- { k: "6a", label: "(a) Provenance & dictionary.", req: true },
152
- {
153
- k: "6b",
154
- label: "(b) Validation / missing data plan.",
155
- req: true,
156
- },
157
- ],
158
- },
159
- {
160
- num: 7,
161
- key: "analysis",
162
- title: "Analysis & AI development",
163
- items: [
164
- { k: "7a", label: "(a) Extract matches approvals.", req: true },
165
- {
166
- k: "7c",
167
- label: "(c) AI docs & bias checks (if AI).",
168
- req: true,
169
- showFor: ["innovation"],
170
- },
171
- ],
172
- },
173
- {
174
- num: 8,
175
- key: "compliance",
176
- title: "Compliance monitoring and auditing",
177
- items: [
178
- { k: "8a", label: "(a) Internal audit.", req: true },
179
- { k: "8b", label: "(b) Keep RoPA up to date.", req: true },
180
- ],
181
- },
182
- {
183
- num: 9,
184
- key: "closeout",
185
- title: "Dissemination, closeout, retention & deletion",
186
- items: [
187
- { k: "9a", label: "(a) Disclosure control OK.", req: true },
188
- {
189
- k: "9c",
190
- label: "(c) Retention/deletion β€” Art. 5(1)(e).",
191
- req: true,
192
- },
193
- ],
194
- },
195
- ];
196
-
197
- /* Flow questions derived from the flowchart */
198
- const FLOW = {
199
- 1: {
200
- q: "Project classification complete & documented?",
201
- onYes: 2,
202
- onNo: 1,
203
- },
204
- 2: { q: "Valid legal route and consent completed?", onYes: 3, onNo: 2 },
205
- 3: {
206
- q: "All ethical and regulatory approvals in place?",
207
- onYes: 4,
208
- onNo: 3,
209
- },
210
- 4: { q: "Data access route & contracts are ok?", onYes: 5, onNo: 4 },
211
- 5: {
212
- q: "Information security & privacy measures and risks controlled?",
213
- onYes: 6,
214
- onNo: 5,
215
- },
216
- 6: { q: "Proceed to analysis?", onYes: 7, onNo: 6 },
217
- /* Step 7 has several conditional checks in order */
218
- 7: [
219
- { q: "Extract matches necessary approvals?", yes: 8, no: 3 },
220
- { q: "Any new purpose?", yes: 1, no: "next" },
221
- { q: "Any new approvals needed?", yes: 3, no: "next" },
222
- { q: "Any new access/contracts?", yes: 4, no: 8 },
223
  ],
224
- 8: { q: "Any gaps found?", onYes: 4, onNo: 9 },
225
- 9: { q: "Retain/reuse beyond plan?", onYes: 1, onNo: "end" },
226
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- /* ------------ State ------------ */
229
- const STORAGE = "suhr_flow_v1";
230
- const $ = (s) => document.querySelector(s);
231
- const $$ = (s) => Array.from(document.querySelectorAll(s));
232
- const state = {
233
- purpose: null,
234
- checks: {},
235
- savedAt: null,
236
- visible: 0,
237
- step: 0,
238
- subQ: 0,
239
- reachedEnd: false, // track if user ever reached the end screen
240
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- /* ------------ Purpose UI ------------ */
243
- function renderPurposes() {
244
- const host = $("#purposeList");
245
- host.innerHTML = "";
246
- PURPOSES.forEach((p) => {
247
- const el = document.createElement("label");
248
- el.className = "option";
249
- el.innerHTML = `
250
- <input type="radio" name="purpose" value="${p.id}">
251
- <div style="display:flex;justify-content:space-between;gap:8px">
252
- <div><b>${p.title}</b><div class="tiny" style="margin-top:4px">${
253
- p.blurb
254
- }</div></div>
255
- <span class="tag gdpr">${
256
- p.id === "anon" ? "No GDPR" : "GDPR"
257
- }</span>
258
- </div>`;
259
- el.addEventListener("click", () => {
260
- state.purpose = p.id;
261
- save(true);
262
- $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon");
263
- if (p.id === "anon") {
264
- hideFlow();
265
- renderDots(0);
266
- $("#timeline").innerHTML = "";
267
- $("#finish").classList.remove("show");
268
- state.reachedEnd = false;
269
- return;
270
- }
271
- // start at step 1
272
- state.visible = 1;
273
- state.step = 1;
274
- state.subQ = 0;
275
- state.reachedEnd = false;
276
- save(true);
277
- $("#finish").classList.remove("show");
278
- renderDots(state.visible);
279
- renderTimeline();
280
- showFlow();
281
- askCurrent(); // auto-expand step 1
282
- });
283
- host.appendChild(el);
284
- });
285
- if (state.purpose) {
286
- $$('input[name="purpose"]').forEach((i) => {
287
- if (i.value === state.purpose) {
288
- i.checked = true;
289
- i.closest(".option").classList.add("active");
290
- }
291
- });
292
- $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon");
293
- }
294
- }
295
 
296
- /* ------------ Timeline (accordion) ------------ */
297
- function renderTimeline() {
298
- const host = $("#timeline");
299
- host.innerHTML = "";
300
- if (!state.purpose || state.purpose === "anon") return;
301
- const v = state.visible || 1;
302
- for (let i = 0; i < v; i++) addStep(host, STEPS[i], i + 1);
303
- expandDetailsForStep(state.step);
304
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
- function addStep(host, step, idx) {
307
- const done = isStepComplete(step);
308
- const sec = document.createElement("section");
309
- sec.className = "step" + (done ? " done" : "");
310
- sec.dataset.step = step.key;
311
- sec.innerHTML = `
312
- <div class="head">
313
- <div class="title">
314
- <span class="num">${step.num}</span>
315
- <div><b>${step.title}</b></div>
316
- </div>
317
- <div style="display:flex;gap:8px;align-items:center">
318
- <span class="badge" id="badge_${step.key}">${
319
- done ? "Complete βœ“" : "In progress"
320
- }</span>
321
- <button class="toggle" data-fold="${step.key}">Details</button>
322
- </div>
323
- </div>
324
- <div class="body fold" id="fold_${step.key}">
325
- <ul class="list"></ul>
326
- </div>`;
327
- const ul = sec.querySelector(".list");
328
- step.items.forEach((it) => {
329
- if (it.showFor && !it.showFor.includes(state.purpose)) return;
330
- const checked = !!state.checks[step.key]?.[it.k];
331
- const li = document.createElement("li");
332
- li.className = "li";
333
- const id = `${step.key}_${it.k}`;
334
- li.innerHTML = `
335
- <div class="letters">${it.k}</div>
336
- <label for="${id}">${it.label}${
337
- it.req
338
- ? ` <span class='req'>β˜…</span>`
339
- : " <span class='opt'>(opt)</span>"
340
- }</label>
341
- <div style="display:flex;align-items:center;gap:8px">
342
- <input class="chk" type="checkbox" id="${id}" ${
343
- checked ? "checked" : ""
344
- }/>
345
- <svg class="box" viewBox="0 0 24 24" aria-hidden="true"><path class="tick" d="M5 13l4 4L19 7"/></svg>
346
- </div>`;
347
- li.querySelector(".chk").addEventListener("change", (e) => {
348
- (state.checks[step.key] ??= {})[it.k] = e.target.checked;
349
- save(true);
350
-
351
- const nowDone = isStepComplete(step);
352
- $("#badge_" + step.key).textContent = nowDone
353
- ? "Complete βœ“"
354
- : "In progress";
355
- if (nowDone && !sec.classList.contains("done")) {
356
- sec.classList.add("done");
357
- if (idx === 1) advanceToStep(2); // auto show Step 2 when Step 1 completes
358
- } else if (!nowDone) {
359
- sec.classList.remove("done");
360
- }
361
-
362
- renderDots(state.visible); // refresh top dots' done state
363
- updateProgress();
364
-
365
- // If the end screen is visible (or was reached before), update/show its message when all steps are complete.
366
- if ($("#finish").classList.contains("show")) {
367
- refreshFinishBanner();
368
- } else if (state.reachedEnd && allStepsComplete()) {
369
- finish();
370
- }
371
- });
372
-
373
- li.addEventListener("click", (e) => {
374
- if (e.target.closest("a, button, input, .toggle")) return;
375
- if (e.target.closest("label")) e.preventDefault();
376
-
377
- const cb = li.querySelector(".chk");
378
- cb.checked = !cb.checked;
379
- cb.dispatchEvent(new Event("change", { bubbles: true }));
380
- });
381
-
382
-
383
-
384
- ul.appendChild(li);
385
- });
386
 
387
- // "Details" toggle
388
- sec.querySelector(".toggle").addEventListener("click", () => {
389
- const pane = document.getElementById("fold_" + step.key);
390
- pane.classList.toggle("fold");
391
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
- host.appendChild(sec);
394
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
- /* Expand current step details and collapse others while asking each question */
397
- function expandDetailsForStep(stepNum) {
398
- if (!stepNum) return;
399
- // collapse all
400
- $$("#timeline .body").forEach((pane) => pane.classList.add("fold"));
401
- const current = STEPS[stepNum - 1];
402
- if (!current) return;
403
- const pane = document.getElementById("fold_" + current.key);
404
- if (pane) pane.classList.remove("fold");
405
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- /* ------------ Dots / progress ------------ */
408
- function renderDots(visible) {
409
- const d = $("#dots");
410
- d.innerHTML = "";
411
- for (let i = 1; i <= STEPS.length; i++) {
412
- const stepObj = STEPS[i - 1];
413
- const stepDone = isStepComplete(stepObj);
414
- const dot = document.createElement("div");
415
- dot.className =
416
- "dot" +
417
- (i <= visible ? " unlocked" : "") +
418
- (i === state.step ? " active" : "") +
419
- (stepDone ? " done" : "");
420
- dot.textContent = i;
421
- dot.setAttribute(
422
- "aria-label",
423
- `Step ${i}${stepDone ? " complete" : ""}`
424
- );
425
- if (i <= visible) {
426
- dot.addEventListener("click", () => {
427
- state.step = i;
428
- state.subQ = 0;
429
- save(true);
430
- askCurrent();
431
- scrollToStep(i);
432
- highlightDot();
433
- });
434
- }
435
- d.appendChild(dot);
436
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  updateProgress();
 
438
  }
439
- function highlightDot() {
440
- $$("#dots .dot").forEach((el, idx) => {
441
- el.classList.toggle("active", idx + 1 === state.step);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  });
443
- }
444
- function scrollToStep(n) {
445
- const el = document.querySelector(`.step:nth-of-type(${n})`);
446
- if (!el) return;
447
- el.scrollIntoView({ behavior: "smooth", block: "start" });
448
- el.animate(
449
- [
450
- { outlineColor: "#56b4e9", outlineWidth: "0px" },
451
- { outlineColor: "#56b4e9", outlineWidth: "6px" },
452
- { outlineColor: "transparent", outlineWidth: "0px" },
453
- ],
454
- { duration: 800 }
455
- );
456
- }
457
 
458
- /* ------------ Flow controller (Yes/No) ------------ */
459
- const flow = $("#flow"),
460
- qtxt = $("#qtxt"),
461
- yesBtn = $("#yesBtn"),
462
- noBtn = $("#noBtn");
463
 
464
- function showFlow() {
465
- flow.classList.remove("hidden");
466
- }
467
- function hideFlow() {
468
- flow.classList.add("hidden");
 
 
 
 
 
469
  }
 
 
 
470
 
471
- function askCurrent() {
472
- // Reset finish visibility text live region while navigating
473
- $("#finish").classList.remove("show");
474
 
475
- if (state.purpose === "anon" || !state.step) {
476
- hideFlow();
477
- return;
478
- }
 
 
479
 
480
- // Ensure the current step's checklist is expanded (and others collapsed)
481
- expandDetailsForStep(state.step);
482
-
483
- // Step 7 special sequence
484
- if (state.step === 7) {
485
- const seq = FLOW[7];
486
- let idx = state.subQ;
487
- if (idx >= seq.length) {
488
- state.step = 8;
489
- state.subQ = 0;
490
- renderDots(state.visible);
491
- askCurrent();
492
- return;
493
- }
494
- const node = seq[idx];
495
- qtxt.textContent = node.q;
496
- yesBtn.onclick = () => {
497
- if (node.yes === "next") {
498
- state.subQ++;
499
- askCurrent();
500
- return;
501
- }
502
- goto(node.yes);
503
- };
504
- noBtn.onclick = () => {
505
- if (node.no === "next") {
506
- state.subQ++;
507
- askCurrent();
508
- return;
509
- }
510
- goto(node.no);
511
- };
512
- return;
513
- }
514
 
515
- // Regular step question
516
- const node = FLOW[state.step];
517
- if (!node) {
518
- hideFlow();
519
- return;
520
- }
521
- qtxt.textContent = node.q;
522
- yesBtn.onclick = () => goto(node.onYes);
523
- noBtn.onclick = () => goto(node.onNo);
524
- }
 
 
 
 
 
 
 
525
 
526
- function goto(dest) {
527
- if (dest === "end") {
528
- finish();
529
- return;
530
- }
531
- // move
532
- state.step = dest;
533
- state.subQ = 0;
534
- if (state.step > state.visible) state.visible = state.step; // grow visibility
535
- renderDots(state.visible);
536
- renderTimeline();
537
- askCurrent();
538
- scrollToStep(state.step);
539
- save(true);
540
- }
541
 
542
- /* Called when step 1 completes to auto reveal step 2 */
543
- function advanceToStep(n) {
544
- if (state.visible < n) {
545
- state.visible = n;
546
- renderDots(state.visible);
547
- renderTimeline();
548
- scrollToStep(n);
549
- }
550
- state.step = n;
 
 
 
 
 
 
 
 
 
551
  state.subQ = 0;
552
- askCurrent();
553
  save(true);
554
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
- /* ------------ Helpers ------------ */
557
- function isStepComplete(step) {
558
- const d = state.checks[step.key] || {};
559
- const req = step.items.filter(
560
- (it) => !(it.showFor && !it.showFor.includes(state.purpose)) && it.req
561
- );
562
- return req.length ? req.every((it) => !!d[it.k]) : true;
563
- }
564
- function allStepsComplete() {
565
- if (!state.purpose || state.purpose === "anon") return false;
566
- return STEPS.every((s) => isStepComplete(s));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  }
568
- function updateProgress() {
569
- let total = 0,
570
- done = 0;
571
- STEPS.forEach((s) => {
572
- if (state.purpose === "anon" && s.num > 1) return;
573
- s.items.forEach((it) => {
574
- if (it.showFor && !it.showFor.includes(state.purpose)) return;
575
- if (it.req) {
576
- total++;
577
- if (state.checks[s.key]?.[it.k]) done++;
578
- }
579
- });
580
- });
581
- const pct = total ? Math.round((100 * done) / total) : 0;
582
- $("#pbar").style.width = pct + "%";
583
  }
 
 
 
 
584
 
585
- /* ------------ Finish ------------ */
586
- function finish() {
587
- hideFlow();
588
- state.reachedEnd = true;
589
- save(true);
 
 
 
 
590
 
591
- const ok = allStepsComplete();
592
- const finishEl = $("#finish");
593
- const titleEl = $("#finishTitle");
594
- const subEl = $("#finishSub");
595
- const confettiHost = $("#confetti");
596
-
597
- if (ok) {
598
- titleEl.textContent = "End πŸŽ‰";
599
- subEl.textContent = "All flow checks passed.";
600
- confettiHost.innerHTML = "";
601
- confetti(24);
602
- } else {
603
- titleEl.textContent = "End";
604
- subEl.textContent = "";
605
- confettiHost.innerHTML = ""; // no confetti if not fully complete
606
- }
607
- finishEl.classList.add("show");
608
- }
609
 
610
- function refreshFinishBanner() {
611
- // Update finish banner live without toggling visibility
612
- const ok = allStepsComplete();
613
- const titleEl = $("#finishTitle");
614
- const subEl = $("#finishSub");
615
- const confettiHost = $("#confetti");
616
-
617
- if (ok) {
618
- titleEl.textContent = "End πŸŽ‰";
619
- subEl.textContent = "All flow checks passed.";
620
- confettiHost.innerHTML = "";
621
- confetti(24);
622
- } else {
623
- titleEl.textContent = "End";
624
- subEl.textContent = "";
625
- confettiHost.innerHTML = "";
626
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
- function confetti(n) {
630
- const host = $("#confetti");
631
- host.innerHTML = "";
632
- for (let i = 0; i < n; i++) {
633
- const piece = document.createElement("i");
634
- piece.style.setProperty("--dx", Math.random() * 300 - 150 + "px");
635
- piece.style.left = 20 + Math.random() * 60 + "%";
636
- piece.style.background = i % 2 ? "#0072B2" : "#E69F00";
637
- piece.style.animationDelay = Math.random() * 0.4 + "s";
638
- host.appendChild(piece);
639
  }
640
- }
641
 
642
- /* ------------ Save/Load ------------ */
643
- function save(quiet = false) {
644
- state.savedAt = Date.now();
645
- localStorage.setItem(STORAGE, JSON.stringify(state));
646
- if (!quiet) updateProgress();
647
  }
648
- function load() {
649
- try {
650
- const raw = localStorage.getItem(STORAGE);
651
- if (raw) Object.assign(state, JSON.parse(raw));
652
- } catch (e) {}
653
  }
 
 
 
 
 
654
 
655
- /* ------------ Clear / Reset ------------ */
656
- function clearAll() {
657
- try {
658
- localStorage.removeItem(STORAGE);
659
- } catch (e) {}
660
- state.purpose = null;
661
- state.checks = {};
662
- state.savedAt = null;
663
- state.visible = 0;
664
- state.step = 0;
665
- state.subQ = 0;
666
- state.reachedEnd = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
 
668
- renderPurposes();
669
- renderDots(0);
670
- $("#timeline").innerHTML = "";
671
- $("#finish").classList.remove("show");
672
- $("#anonMsg").classList.add("hidden");
673
- hideFlow();
674
- updateProgress();
675
- }
 
 
 
 
676
 
677
- /* ------------ Boot ------------ */
678
- load();
679
- renderPurposes();
680
- if (state.purpose && state.purpose !== "anon") {
681
- state.visible = state.visible || 1;
682
- state.step = state.step || 1;
683
- renderDots(state.visible);
684
- renderTimeline();
685
- showFlow();
686
- askCurrent();
687
- } else {
688
- hideFlow();
689
- }
690
- updateProgress();
691
-
692
- // Clear button handler
693
- document.getElementById("clearBtn").addEventListener("click", () => {
694
- const ok = confirm(
695
- "Clear all saved progress and do fresh start? This will remove your selections!"
696
- );
697
- if (ok) clearAll();
698
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
+ const PURPOSES = [
3
+ { id: "research", title: "Health Research", blurb: "Scientific knowledge.", refs: [] },
4
+ { id: "quality", title: "Quality Improvement", blurb: "Improve your own service.", refs: [] },
5
+ { id: "statistics", title: "Statistics / Official", blurb: "Org/regional/national stats.", refs: [] },
6
+ { id: "innovation", title: "Innovation / AI", blurb: "Tool/algorithm/model.", refs: [] },
7
+ { id: "anon", title: "Exclusively Anonymized Data", blurb: "No reasonable reidentification risk.", refs: [] },
8
+ ];
9
+
10
+ const STEPS = [
11
+ {
12
+ num: 1,
13
+ key: "classification",
14
+ title: "Project classification",
15
+ items: [
16
+ {
17
+ k: "1a",
18
+ label:
19
+ "Classify per Figure 1; record purpose, controller(s)/processor(s), roles and identifiability.",
20
+ req: true,
21
+ refs: [
22
+ { title: "HRA Β§2 (scope)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A72" },
23
+ { title: "HRA Β§4 (definitions)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A74" },
24
+ { title: "GDPR Art. 4; Art. 4(7)", url: "https://gdpr-info.eu/art-4-gdpr/" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ],
26
+ },
27
+ {
28
+ k: "1b",
29
+ label:
30
+ "Identify data status: personal / special category (health/genetic/biometric) / anonymized. Pseudonymized data remains personal; genetics falls under biotechnology.",
31
+ req: true,
32
+ refs: [
33
+ { title: "GDPR Art. 9(1) (special)", url: "https://gdpr-info.eu/art-9-gdpr/" },
34
+ { title: "GDPR Recital 26 (anonymized)", url: "https://gdpr-info.eu/recitals/no-26/" },
35
+ { title: "GDPR Art. 4(5) (pseudonymized)", url: "https://gdpr-info.eu/art-4-gdpr/" }
36
+ ],
37
+ },
38
+ {
39
+ k: "1c",
40
+ label:
41
+ "If medical/health research: HRA applies and REK decides scope (before access).",
42
+ req: true,
43
+ showFor: ["research"],
44
+ refs: [
45
+ { title: "HRA Β§2; Β§4; REK scope", url: "https://lovdata.no/lov/2008-06-20-44" }
46
+ ],
47
+ },
48
+ {
49
+ k: "1d",
50
+ label:
51
+ "If using health registers: record registry restrictions.",
52
+ req: false,
53
+ showFor: ["statistics", "research", "quality", "innovation"],
54
+ refs: [
55
+ { title: "HRL Β§Β§8–11 (registers)", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }
56
+ ],
57
+ },
58
+ {
59
+ k: "1e",
60
+ label:
61
+ "Non-medical/health research: consult health trusts, use Figure 4, consult DPO, retain written rationale.",
62
+ req: true,
63
+ showFor: ["quality", "statistics", "innovation"],
64
+ refs: [
65
+ { title: "GDPR Art. 5(2) (accountability)", url: "https://gdpr-info.eu/art-5-gdpr/" },
66
+ { title: "GDPR Art. 24(1) (responsibility)", url: "https://gdpr-info.eu/art-24-gdpr/" }
67
+ ],
68
+ },
69
+ {
70
+ k: "1f",
71
+ label:
72
+ "Plan periodic re-review as tech/law evolve (accountability).",
73
+ req: false,
74
+ refs: [{ title: "GDPR Art. 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }],
75
+ },
76
+ ],
77
+ },
78
 
79
+ {
80
+ num: 2,
81
+ key: "legal",
82
+ title: "Legal basis & consent",
83
+ items: [
84
+ {
85
+ k: "2a",
86
+ label:
87
+ "Select legal route (public interest/official authority β€” 6(1)(e); research/statistics β€” 9(2)(j) with 89(1) safeguards; healthcare/management/QI β€” 9(2)(h)).",
88
+ req: true,
89
+ refs: [
90
+ { title: "GDPR Art. 6(1)(e)", url: "https://gdpr-info.eu/art-6-gdpr/" },
91
+ { title: "GDPR Art. 9(2)(h)/(j)", url: "https://gdpr-info.eu/art-9-gdpr/" },
92
+ { title: "GDPR Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" }
93
+ ],
94
+ },
95
+ {
96
+ k: "2b",
97
+ label:
98
+ "DPIA where processing is likely high risk; document rationale if not.",
99
+ req: true,
100
+ refs: [{ title: "GDPR Art. 35 (DPIA)", url: "https://gdpr-info.eu/art-35-gdpr/" }],
101
+ },
102
+ {
103
+ k: "2c",
104
+ label:
105
+ "Health-law confidentiality satisfied via valid consent or dispensation (archival/research/statistics in public interest).",
106
+ req: true,
107
+ refs: [
108
+ { title: "HPA Β§21 (confidentiality)", url: "https://lovdata.no/lov/1999-07-02-64/%C2%A721" },
109
+ { title: "HPA Β§29; HRL Β§19e (dispensation)", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }
110
+ ],
111
+ },
112
+ {
113
+ k: "2d",
114
+ label:
115
+ "If using consent: GDPR-valid; information duties; withdrawal covered (re-consent if needed).",
116
+ req: false,
117
+ refs: [
118
+ { title: "GDPR Art. 7 (consent)", url: "https://gdpr-info.eu/art-7-gdpr/" },
119
+ { title: "GDPR Arts. 13–14 (information)", url: "https://gdpr-info.eu/art-13-gdpr/" },
120
+ { title: "HRA Β§13 (consent, if HRA)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A713" }
121
+ ],
122
+ },
123
+ ],
124
+ },
125
 
126
+ {
127
+ num: 3,
128
+ key: "ethics",
129
+ title: "Ethics & regulatory approvals",
130
+ items: [
131
+ {
132
+ k: "3a",
133
+ label: "REK approval or exemption before research access/use.",
134
+ req: true,
135
+ showFor: ["research"],
136
+ refs: [
137
+ { title: "HRA Β§9; Β§33", url: "https://lovdata.no/lov/2008-06-20-44" }
138
+ ],
139
+ },
140
+ {
141
+ k: "3b",
142
+ label:
143
+ "Take into account ethics/regulators (REK), DPA (Datatilsynet), registry/data holders (e.g., linkage constraints).",
144
+ req: true,
145
+ refs: [
146
+ { title: "Datatilsynet β€” rules/tools", url: "https://www.datatilsynet.no/regelverk-og-verktoy/lover-og-regler/" }
147
+ ],
148
+ },
149
+ {
150
+ k: "3c",
151
+ label: "RoPA & transparency notices ready.",
152
+ req: true,
153
+ refs: [
154
+ { title: "GDPR Art. 30 (RoPA)", url: "https://gdpr-info.eu/art-30-gdpr/" },
155
+ { title: "GDPR Arts. 13–14 (transparency)", url: "https://gdpr-info.eu/art-13-gdpr/" }
156
+ ],
157
+ },
158
+ {
159
+ k: "3d",
160
+ label: "Include AI details in materials where applicable; plan obligations.",
161
+ req: true,
162
+ showFor: ["innovation"],
163
+ refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }],
164
+ },
165
+ ],
166
+ },
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ {
169
+ num: 4,
170
+ key: "access",
171
+ title: "Data access & agreements",
172
+ items: [
173
+ {
174
+ k: "4a",
175
+ label:
176
+ "Obtain DUA/DSA (or equivalent) with data provider: scope, purpose, duration, security, confidentiality, post-hoc control, return/destruction.",
177
+ req: true,
178
+ },
179
+ {
180
+ k: "4b",
181
+ label:
182
+ "Security controls for controller/processor incl. access control & logging.",
183
+ req: true,
184
+ refs: [
185
+ { title: "GDPR Art. 32 (security)", url: "https://gdpr-info.eu/art-32-gdpr/" },
186
+ { title: "PRA Β§22; PRR Β§14 (logging)", url: "https://lovdata.no/forskrift/2019-03-01-168/%C2%A714" },
187
+ { title: "Normen Β§5.2; Β§5.4.4", url: "https://www.helsedirektoratet.no/english/the-code-of-conduct-for-information-security-and-data-protection" }
188
+ ],
189
+ },
190
+ {
191
+ k: "4c",
192
+ label:
193
+ "Team confidentiality/training completed (duty of confidentiality).",
194
+ req: true,
195
+ refs: [
196
+ { title: "HPA Β§21", url: "https://lovdata.no/lov/1999-07-02-64/%C2%A721" }
197
+ ],
198
+ },
199
+ {
200
+ k: "4d",
201
+ label:
202
+ "Use SPE/SAE (e.g., TSD/HUNT Cloud/SAFE). Transfers via secure channels (logging, access control, encryption).",
203
+ req: true,
204
+ refs: [
205
+ { title: "GDPR Art. 32", url: "https://gdpr-info.eu/art-32-gdpr/" },
206
+ { title: "Normen (logging/encryption)", url: "https://www.helsedirektoratet.no/normen/logging-og-innsyn-i-logg-faktaark-15" }
207
+ ],
208
+ },
209
+ {
210
+ k: "4e",
211
+ label:
212
+ "If data comes from an external source (outside your institution: registries/health trusts).",
213
+ req: false,
214
+ children: [
215
+ {
216
+ k: "4e-1",
217
+ label:
218
+ "Obtain data access agreements; dispensation where applicable.",
219
+ req: true,
220
+ refs: [
221
+ { title: "HRL Β§19e; HPA Β§29", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }
222
+ ],
223
+ },
224
+ {
225
+ k: "4e-2",
226
+ label:
227
+ "Processor/joint controller contracts (GDPR 28(3), 26) and DSA/DUA signed (scope, duration, security, destruction/return).",
228
+ req: true,
229
+ refs: [
230
+ { title: "GDPR Art. 28(3)", url: "https://gdpr-info.eu/art-28-gdpr/" },
231
+ { title: "GDPR Art. 26", url: "https://gdpr-info.eu/art-26-gdpr/" }
232
+ ],
233
+ },
234
+ ],
235
+ },
236
+ {
237
+ k: "4f",
238
+ label:
239
+ "Maximum permitted retention/access period set and followed.",
240
+ req: true,
241
+ refs: [{ title: "HRL Β§19f", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }],
242
+ },
243
+ {
244
+ k: "4g",
245
+ label:
246
+ "Agreement aligns with SPE/Secure Processing Environment checklists.",
247
+ req: true,
248
+ },
249
+ {
250
+ k: "4h",
251
+ label:
252
+ "EHDS alignment for cross-border/secondary use (permit; SPE; permitted purposes).",
253
+ req: false,
254
+ refs: [
255
+ { 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" }
256
+ ],
257
+ },
258
+ ],
259
+ },
260
 
261
+ {
262
+ num: 5,
263
+ key: "security",
264
+ title: "Data security & privacy",
265
+ items: [
266
+ {
267
+ k: "5a",
268
+ label: "Pseudonymize where possible; keep keys separate.",
269
+ req: true,
270
+ refs: [{ title: "GDPR Art. 4(5)", url: "https://gdpr-info.eu/art-4-gdpr/" }],
271
+ },
272
+ {
273
+ k: "5b",
274
+ label:
275
+ "Encrypted storage in transit/at rest, RBAC, MFA, network segregation, key mgmt, logging with risk-based review.",
276
+ req: true,
277
+ refs: [
278
+ { title: "GDPR Art. 32", url: "https://gdpr-info.eu/art-32-gdpr/" },
279
+ { title: "PRA Β§22; PRR Β§14; Normen factsheets", url: "https://www.helsedirektoratet.no/normen/logging-og-innsyn-i-logg-faktaark-15" }
280
+ ],
281
+ },
282
+ {
283
+ k: "5c",
284
+ label:
285
+ "Derived datasets with personal data protected equally.",
286
+ req: true,
287
+ refs: [{ title: "GDPR Art. 5(1)(f); Art. 32", url: "https://gdpr-info.eu/art-5-gdpr/" }],
288
+ },
289
+ {
290
+ k: "5d",
291
+ label:
292
+ "DPIA done before analysis (large-scale health/innovative AI/vulnerable groups) or rationale recorded.",
293
+ req: true,
294
+ refs: [{ title: "GDPR Art. 35", url: "https://gdpr-info.eu/art-35-gdpr/" }],
295
+ },
296
+ {
297
+ k: "5e",
298
+ label:
299
+ "Consult DPO as required; record advice and implement recommendations.",
300
+ req: false,
301
+ refs: [{ title: "GDPR Arts. 37–39", url: "https://gdpr-info.eu/chapter-4/" }],
302
+ },
303
+ {
304
+ k: "5f",
305
+ label:
306
+ "Sharing within NO/EU: verify permission, lawful basis/principles; respect IP/licensing/REK terms.",
307
+ req: false,
308
+ refs: [
309
+ { title: "GDPR Art. 5(1)(b)-(c); Art. 6(1)", url: "https://gdpr-info.eu/art-5-gdpr/" },
310
+ { title: "GDPR Arts. 44–49 (transfers)", url: "https://gdpr-info.eu/chapter-5/" },
311
+ { title: "HRA Β§33 (conditions)", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A733" }
312
+ ],
313
+ },
314
+ {
315
+ k: "5g",
316
+ label:
317
+ "Transfers outside EEA: lawful mechanism (adequacy/SCCs/derogations); TIA documented; approvals/logs kept.",
318
+ req: true,
319
+ refs: [
320
+ { title: "GDPR Arts. 44–46", url: "https://gdpr-info.eu/chapter-5/" },
321
+ { title: "GDPR Art. 49", url: "https://gdpr-info.eu/art-49-gdpr/" }
322
+ ],
323
+ },
324
+ {
325
+ k: "5h",
326
+ label:
327
+ "Third-party tools/vendors: GDPR 28 DPAs; safeguards vetted.",
328
+ req: true,
329
+ refs: [{ title: "GDPR Art. 28", url: "https://gdpr-info.eu/art-28-gdpr/" }],
330
+ },
331
+ {
332
+ k: "5i",
333
+ label:
334
+ "Data breach: notify DPA within 72h where required; assess duty to inform subjects.",
335
+ req: true,
336
+ refs: [{ title: "GDPR Arts. 33–34", url: "https://gdpr-info.eu/art-33-gdpr/" }],
337
+ },
338
+ { k: "5j", label: "Periodic review of security (incl. SPE/SAE).", req: true },
339
+ ],
340
+ },
341
 
342
+ {
343
+ num: 6,
344
+ key: "quality",
345
+ title: "Data minimisation & quality",
346
+ items: [
347
+ {
348
+ k: "6a",
349
+ label:
350
+ "Identify & extract only necessary/approved fields (minimisation & purpose limitation).",
351
+ req: true,
352
+ refs: [{ title: "GDPR Art. 5(1)(b)-(c)", url: "https://gdpr-info.eu/art-5-gdpr/" }],
353
+ },
354
+ {
355
+ k: "6b",
356
+ label:
357
+ "Post-extraction sweeps for unexpected/disallowed content (free text/images) unless approved.",
358
+ req: true,
359
+ refs: [{ title: "GDPR Art. 5(1)(c); Art. 25", url: "https://gdpr-info.eu/art-25-gdpr/" }],
360
+ },
361
+ {
362
+ k: "6c",
363
+ label:
364
+ "Conform to approvals, scope & objectives.",
365
+ req: true,
366
+ },
367
+ {
368
+ k: "6d",
369
+ label:
370
+ "Periodic data quality review aligned with emerging standards/regulatory updates (accuracy/accountability).",
371
+ req: true,
372
+ refs: [{ title: "GDPR Art. 5(1)(d); 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }],
373
+ },
374
+ ],
375
+ },
376
 
377
+ {
378
+ num: 7,
379
+ key: "analysis",
380
+ title: "Analysis & AI development",
381
+ items: [
382
+ {
383
+ k: "7a",
384
+ label:
385
+ "Analyse strictly within SPE/SAE; validated tools; no re-identification unless legally authorised.",
386
+ req: true,
387
+ refs: [{ title: "GDPR Art. 5(1)(a)-(b)", url: "https://gdpr-info.eu/art-5-gdpr/" }],
388
+ },
389
+ {
390
+ k: "7b",
391
+ label:
392
+ "Maintain comprehensive preprocessing documentation (cleaning/transforms/anonymity) & technical documentation.",
393
+ req: true,
394
+ refs: [
395
+ { title: "AI Act Art. 10; Annex XI–XII", url: "https://ai-act-law.eu/article/10/" },
396
+ { title: "GDPR Art. 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }
397
+ ],
398
+ },
399
+ {
400
+ k: "7c",
401
+ label:
402
+ "Perform an AI risk classification assessment; document (classification framework, Annex III categories).",
403
+ req: true,
404
+ showFor: ["innovation"],
405
+ refs: [
406
+ { title: "AI Act Arts. 6–7; Annex III", url: "https://ai-act-law.eu/article/6/" }
407
+ ],
408
+ children: [
409
+ {
410
+ k: "7c-1",
411
+ label:
412
+ "High-risk AI: implement risk mgmt & controls; define human oversight; validate performance; manage drift; ensure cybersecurity.",
413
+ req: true,
414
+ refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }],
415
+ },
416
+ {
417
+ k: "7c-2",
418
+ label:
419
+ "High-risk AI: data governance for train/val/test β€” representative, relevant, accurate; bias assessed.",
420
+ req: true,
421
+ refs: [{ title: "AI Act Art. 10(2)", url: "https://ai-act-law.eu/article/10/" }],
422
+ },
423
+ {
424
+ k: "7c-3",
425
+ label:
426
+ "Non-high-risk AI: adopt good practice on data quality, bias (subgroup eval/mitigation/monitoring), transparency & human oversight; consider voluntary codes.",
427
+ req: false,
428
+ refs: [{ title: "AI Act Arts. 95–96", url: "https://ai-act-law.eu/article/95/" }],
429
+ },
430
+ {
431
+ k: "7c-4",
432
+ label:
433
+ "Maintain technical documentation & record-keeping (model, algorithms, data sources, intended purpose).",
434
+ req: true,
435
+ refs: [
436
+ { title: "AI Act Art. 11 (tech docs)", url: "https://ai-act-law.eu/article/11/" },
437
+ { title: "AI Act Art. 12 (records)", url: "https://ai-act-law.eu/article/12/" }
438
+ ],
439
+ },
440
+ {
441
+ k: "7c-5",
442
+ label:
443
+ "For AI projects: ongoing obligations β€” risk mgmt, data governance, docs, record-keeping, transparency, human oversight, accuracy & cybersecurity.",
444
+ req: true,
445
+ refs: [{ title: "AI Act Arts. 9–15", url: "https://ai-act-law.eu/article/9/" }],
446
+ },
447
+ {
448
+ k: "7c-6",
449
+ label:
450
+ "If developing an AI model: verify permission & GDPR legal basis; remove personal data or ensure vetted/validated use; respect IP/licensing & REK terms.",
451
+ req: true,
452
+ refs: [
453
+ { title: "AI Act Art. 2(7) (scope)", url: "https://ai-act-law.eu/article/2/" },
454
+ { title: "GDPR Art. 5(1)(b)-(c); Art. 6(1); Arts. 44–49", url: "https://gdpr-info.eu/art-6-gdpr/" }
455
+ ],
456
+ },
457
+ {
458
+ k: "7c-7",
459
+ label:
460
+ "If deploying on EU market: conformity/CE/registration in EU AI database where required.",
461
+ req: false,
462
+ refs: [{ title: "AI Act Arts. 30 & 43", url: "https://ai-act-law.eu/article/30/" }],
463
+ },
464
+ {
465
+ k: "7c-8",
466
+ label:
467
+ "Risk mgmt & bias mitigation documented for interpretation of results.",
468
+ req: true,
469
+ refs: [
470
+ { title: "AI Act Art. 9; Art. 10(2)(e)", url: "https://ai-act-law.eu/article/9/" },
471
+ { title: "GDPR Art. 35 (DPIA)", url: "https://gdpr-info.eu/art-35-gdpr/" },
472
+ ],
473
+ },
474
+ {
475
+ k: "7c-9",
476
+ label:
477
+ "Exclude malpractice data; run fairness checks (e.g., demographic parity, equalised odds, subgroup analysis); schedule bias audits & compliance reviews; handle subject rights.",
478
+ req: true,
479
+ refs: [
480
+ { title: "AI Act Arts. 9, 10(2)(e), 15", url: "https://ai-act-law.eu/article/9/" },
481
+ { title: "GDPR Arts. 12–22 (rights)", url: "https://gdpr-info.eu/chapter-3/" }
482
+ ],
483
+ },
484
+ {
485
+ k: "7c-10",
486
+ label:
487
+ "Plan periodic reassessment & review as tech/regulations evolve.",
488
+ req: true,
489
+ },
490
+ ],
491
+ },
492
+ ],
493
+ },
494
 
495
+ {
496
+ num: 8,
497
+ key: "compliance",
498
+ title: "Compliance monitoring & auditing",
499
+ items: [
500
+ {
501
+ k: "8a",
502
+ label:
503
+ "Internal audit (e.g., by/with health trust); remediate discrepancies.",
504
+ req: true,
505
+ refs: [{ title: "GDPR Art. 5(2); Art. 24", url: "https://gdpr-info.eu/art-24-gdpr/" }],
506
+ },
507
+ { k: "8b", label: "Maintain a compliance report (docs/approvals/permits/agreements).", req: true },
508
+ {
509
+ k: "8c",
510
+ label:
511
+ "All source-system extractions logged (who/when/what); periodic risk-based log review.",
512
+ req: true,
513
+ refs: [{ title: "PRA Β§22; PRR Β§14; Normen 5.4.4", url: "https://lovdata.no/forskrift/2019-03-01-168/%C2%A714" }],
514
+ },
515
+ {
516
+ k: "8d",
517
+ label:
518
+ "Use only for approved protocol; seek amendments before new purposes or analyses.",
519
+ req: true,
520
+ refs: [{ title: "GDPR Art. 5(1)(b); HRA Β§33", url: "https://gdpr-info.eu/art-5-gdpr/" }],
521
+ },
522
+ {
523
+ k: "8e",
524
+ label:
525
+ "Review & update audit/compliance process periodically (law & technology changes).",
526
+ req: true,
527
+ },
528
+ ],
529
+ },
530
 
531
+ {
532
+ num: 9,
533
+ key: "closeout",
534
+ title: "Dissemination, close-out, retention & deletion",
535
+ items: [
536
+ {
537
+ k: "9a",
538
+ label:
539
+ "Before releasing results, ensure no individual can be identified in outputs.",
540
+ req: true,
541
+ refs: [{ title: "GDPR Art. 5(1)(c); Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" }],
542
+ },
543
+ {
544
+ k: "9b",
545
+ label:
546
+ "Publications/reports include ethics & data-protection statements (e.g., REK ref., GDPR compliance; Helsinki where applicable).",
547
+ req: false,
548
+ },
549
+ {
550
+ k: "9c",
551
+ label:
552
+ "If an AI product/service: disclose intended use, limitations, validation status; transparency.",
553
+ req: false,
554
+ refs: [{ title: "AI Act Art. 13", url: "https://ai-act-law.eu/article/13/" }],
555
+ },
556
+ {
557
+ k: "9d",
558
+ label:
559
+ "Fulfil reporting obligations (REK/registries/funders; EU AI database if required).",
560
+ req: false,
561
+ refs: [{ title: "AI Act Chapter III", url: "https://ai-act-law.eu/chapter/3/" }],
562
+ },
563
+ {
564
+ k: "9e",
565
+ label: "Post-project closure actions (expand when ready).",
566
+ req: false,
567
+ children: [
568
+ {
569
+ k: "9e-1",
570
+ label:
571
+ "Delete/anonymize all personal data at retention end; double-check backups & stray copies; document date & method.",
572
+ req: true,
573
+ refs: [{ title: "GDPR Art. 5(1)(e); 5(2)", url: "https://gdpr-info.eu/art-5-gdpr/" }],
574
+ },
575
+ {
576
+ k: "9e-2",
577
+ label:
578
+ "Archive key documentation securely (approvals, consent, scripts, final reports, DPIA), typically 5–10 years without identifiable raw data.",
579
+ req: true,
580
+ refs: [{ title: "GDPR Art. 5(1)(e); Art. 89(1)", url: "https://gdpr-info.eu/art-89-gdpr/" }],
581
+ },
582
+ {
583
+ k: "9e-3",
584
+ label:
585
+ "If retaining/reusing data: record new legal basis (renewed consent, extended REK, or lawful data bank/registry/biobank).",
586
+ req: true,
587
+ refs: [
588
+ { title: "GDPR Art. 6(1); Art. 9(2)", url: "https://gdpr-info.eu/art-6-gdpr/" },
589
+ { title: "HRA Β§Β§13–14; Β§33", url: "https://lovdata.no/lov/2008-06-20-44/%C2%A713" },
590
+ { title: "HRL rules", url: "https://lovdata.no/dokument/NL/lov/2014-06-20-43" }
591
+ ],
592
+ },
593
+ {
594
+ k: "9e-4",
595
+ label:
596
+ "Final debrief meeting; note best practices and recommendations.",
597
+ req: false,
598
+ },
599
+ {
600
+ k: "9e-5",
601
+ label:
602
+ "Notify relevant bodies (e.g., REK) that the study is concluded and data destroyed; update RoPA to mark project finished.",
603
+ req: true,
604
+ refs: [{ title: "GDPR Art. 30 (RoPA)", url: "https://gdpr-info.eu/art-30-gdpr/" }],
605
+ },
606
+ {
607
+ k: "9e-6",
608
+ label:
609
+ "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.",
610
+ req: false,
611
+ refs: [{ title: "AI Act Arts. 9–15; 30; 43", url: "https://ai-act-law.eu/article/9/" }],
612
+ },
613
+ ],
614
+ },
615
+ ],
616
+ },
617
+ ];
618
+
619
+ /* Flow questions*/
620
+ const FLOW = {
621
+ 1: { q: "Project classification complete & documented?", onYes: 2, onNo: 1 },
622
+ 2: { q: "Valid legal route and consent completed?", onYes: 3, onNo: 2 },
623
+ 3: { q: "All ethical and regulatory approvals in place?", onYes: 4, onNo: 3 },
624
+ 4: { q: "Data access route & contracts are ok?", onYes: 5, onNo: 4 },
625
+ 5: {
626
+ q: "Information security & privacy measures and risks controlled?",
627
+ onYes: 6,
628
+ onNo: 5,
629
+ },
630
+ 6: { q: "Extract matches necessary data approvals?", onYes: 7, onNo: 6 },
631
+ /* Step 7 has several conditional questions */
632
+ 7: [
633
+ { q: "Any new purpose?", yes: 1, no: "next" },
634
+ { q: "Any new approvals needed?", yes: 3, no: "next" },
635
+ { q: "Any new access/contracts?", yes: 4, no: 8 },
636
+ ],
637
+ 8: { q: "Any gaps found?", onYes: 4, onNo: 9 },
638
+ 9: { q: "Will you retain or reuse data beyond approved plan?", onYes: 1, onNo: "end" },
639
+ };
640
+
641
+ /* ------------ State ------------ */
642
+ const STORAGE = "suhr_flow_v2_nested";
643
+ const $ = (s) => document.querySelector(s);
644
+ const $$ = (s) => Array.from(document.querySelectorAll(s));
645
+ const state = {
646
+ purpose: null,
647
+ checks: {},
648
+ savedAt: null,
649
+ visible: 0,
650
+ step: 0,
651
+ subQ: 0,
652
+ reachedEnd: false,
653
+ };
654
+
655
+ /* ------------ Purpose UI ------------ */
656
+ function renderPurposes() {
657
+ const host = $("#purposeList");
658
+ host.innerHTML = "";
659
+ PURPOSES.forEach((p) => {
660
+ const el = document.createElement("label");
661
+ el.className = "option";
662
+ el.innerHTML = `
663
+ <input type="radio" name="purpose" value="${p.id}">
664
+ <div style="display:flex;justify-content:space-between;gap:8px">
665
+ <div><b>${p.title}</b><div class="tiny" style="margin-top:4px">${p.blurb}</div></div>
666
+ <span class="tag gdpr">${p.id === "anon" ? "No GDPR" : "GDPR"}</span>
667
+ </div>`;
668
+
669
+ el.addEventListener("click", (e) => {
670
+ host.querySelectorAll(".option").forEach(opt => opt.classList.remove("active"));
671
+ el.classList.add("active");
672
+ host.querySelectorAll('input[name="purpose"]').forEach(inp => {
673
+ inp.checked = (inp.value === p.id);
674
+ });
675
+
676
+ state.purpose = p.id;
677
+ save(true);
678
+
679
+ $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon");
680
+
681
+ if (p.id === "anon") {
682
+ hideFlow();
683
+ renderDots(0);
684
+ $("#timeline").innerHTML = "";
685
+ $("#finish").classList.remove("show");
686
+ state.reachedEnd = false;
687
  updateProgress();
688
+ return;
689
  }
690
+
691
+ state.visible = Math.max(1, state.visible || 1);
692
+ state.step = 1;
693
+ state.subQ = 0;
694
+ state.reachedEnd = false;
695
+ save(true);
696
+ $("#finish").classList.remove("show");
697
+ renderDots(state.visible);
698
+ renderTimeline();
699
+ showFlow();
700
+ askCurrent();
701
+ });
702
+
703
+ host.appendChild(el);
704
+ });
705
+
706
+ if (state.purpose) {
707
+ $$('input[name="purpose"]').forEach((i) => {
708
+ const isMatch = i.value === state.purpose;
709
+ i.checked = isMatch;
710
+ if (isMatch) i.closest(".option").classList.add("active");
711
+ });
712
+ $("#anonMsg").classList.toggle("hidden", state.purpose !== "anon");
713
+ }
714
+ }
715
+
716
+ /* ------------ Timeline (accordion) ------------ */
717
+ function renderTimeline() {
718
+ const host = $("#timeline");
719
+ host.innerHTML = "";
720
+ if (!state.purpose || state.purpose === "anon") return;
721
+ const v = state.visible || 1;
722
+ for (let i = 0; i < v; i++) addStep(host, STEPS[i], i + 1);
723
+ expandDetailsForStep(state.step);
724
+ }
725
+
726
+ function addStep(host, step, idx) {
727
+ const done = isStepComplete(step);
728
+ const sec = document.createElement("section");
729
+ sec.className = "step" + (done ? " done" : "");
730
+ sec.dataset.step = step.key;
731
+ sec.innerHTML = `
732
+ <div class="head">
733
+ <div class="title">
734
+ <span class="num">${step.num}</span>
735
+ <div><b>${step.title}</b></div>
736
+ </div>
737
+ <div style="display:flex;gap:8px;align-items:center">
738
+ <span class="badge" id="badge_${step.key}">${done ? "Complete βœ“" : "In progress"}</span>
739
+ <button class="toggle" data-fold="${step.key}" aria-expanded="${!done}">Details</button>
740
+ </div>
741
+ </div>
742
+ <div class="body fold" id="fold_${step.key}">
743
+ <ul class="list"></ul>
744
+ </div>`;
745
+ const ul = sec.querySelector(".list");
746
+
747
+ step.items.forEach((it) => {
748
+ if (it.showFor && !it.showFor.includes(state.purpose)) return;
749
+ const checked = !!state.checks[step.key]?.[it.k];
750
+ const li = document.createElement("li");
751
+ li.className = "li" + (it.children && it.children.length ? " has-children" : "");
752
+ const id = `${step.key}_${it.k}`;
753
+
754
+ const refsHtml =
755
+ it.refs && it.refs.length
756
+ ? ` <small class="refs">` +
757
+ it.refs
758
+ .map(
759
+ (r) =>
760
+ `<a href="${r.url}" target="_blank" rel="noreferrer noopener">${(r.title || r.url)}</a>`
761
+ )
762
+ .join(" β€’ ") +
763
+ `</small>`
764
+ : "";
765
+
766
+ li.innerHTML = `
767
+ <div class="letters">${it.k}</div>
768
+ <label for="${id}">${it.label}${
769
+ it.req ? ` <span class='req'>β˜…</span>` : " <span class='opt'>(opt)</span>"
770
+ }${refsHtml}</label>
771
+ <div style="display:flex;align-items:center;gap:8px">
772
+ <input class="chk" type="checkbox" id="${id}" ${checked ? "checked" : ""}/>
773
+ <svg class="box" viewBox="0 0 24 24" aria-hidden="true"><path class="tick" d="M5 13l4 4L19 7"/></svg>
774
+ </div>`;
775
+
776
+
777
+ li.addEventListener("click", (e) => {
778
+ if (e.target.closest("a, button, input, .toggle")) return;
779
+ if (e.target.closest(".sublist")) return;
780
+ if (e.target.closest("label")) e.preventDefault();
781
+ const cb = li.querySelector(".chk");
782
+ cb.checked = !cb.checked;
783
+ cb.dispatchEvent(new Event("change", { bubbles: true }));
784
+ });
785
+
786
+ let sublist;
787
+ if (it.children && it.children.length) {
788
+ sublist = document.createElement("ul");
789
+ sublist.className = "sublist";
790
+ sublist.style.display = checked ? "block" : "none";
791
+
792
+ it.children.forEach((ch) => {
793
+ if (ch.showFor && !ch.showFor.includes(state.purpose)) return;
794
+ const cChecked = !!state.checks[step.key]?.[ch.k];
795
+ const cli = document.createElement("li");
796
+ cli.className = "li subitem";
797
+ const cid = `${step.key}_${ch.k}`;
798
+
799
+ const cRefsHtml =
800
+ ch.refs && ch.refs.length
801
+ ? ` <small class="refs">` +
802
+ ch.refs
803
+ .map(
804
+ (r) =>
805
+ `<a href="${r.url}" target="_blank" rel="noreferrer noopener">${(r.title || r.url)}</a>`
806
+ )
807
+ .join(" β€’ ") +
808
+ `</small>`
809
+ : "";
810
+
811
+ cli.innerHTML = `
812
+ <div class="letters">${ch.k}</div>
813
+ <label for="${cid}">${ch.label}${
814
+ ch.req ? ` <span class='req'>β˜…</span>` : " <span class='opt'>(opt)</span>"
815
+ }${cRefsHtml}</label>
816
+ <div style="display:flex;align-items:center;gap:8px">
817
+ <input class="chk" type="checkbox" id="${cid}" ${cChecked ? "checked" : ""}/>
818
+ <svg class="box" viewBox="0 0 24 24" aria-hidden="true"><path class="tick" d="M5 13l4 4L19 7"/></svg>
819
+ </div>`;
820
+
821
+ cli.addEventListener("click", (e) => {
822
+ if (e.target.closest("a, button, input, .toggle")) { e.stopPropagation(); return; }
823
+ if (e.target.closest("label")) e.preventDefault();
824
+ const ccb = cli.querySelector(".chk");
825
+ ccb.checked = !ccb.checked;
826
+ ccb.dispatchEvent(new Event("change", { bubbles: true }));
827
+ e.stopPropagation();
828
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
 
830
+ cli.querySelector(".chk").addEventListener("change", (e) => {
831
+ (state.checks[step.key] ??= {})[ch.k] = e.target.checked;
832
+ save(true);
833
+ updateStepBadgeAndProgress(step, sec, idx);
834
+ });
835
 
836
+ sublist.appendChild(cli);
837
+ });
838
+
839
+ li.appendChild(sublist);
840
+ }
841
+
842
+ li.querySelector(".chk").addEventListener("change", (e) => {
843
+ (state.checks[step.key] ??= {})[it.k] = e.target.checked;
844
+ if (sublist) {
845
+ sublist.style.display = e.target.checked ? "block" : "none";
846
  }
847
+ save(true);
848
+ updateStepBadgeAndProgress(step, sec, idx);
849
+ });
850
 
851
+ ul.appendChild(li);
852
+ });
 
853
 
854
+ const detailsBtn = sec.querySelector(".toggle");
855
+ detailsBtn.addEventListener("click", () => {
856
+ const pane = document.getElementById("fold_" + step.key);
857
+ const nowFold = pane.classList.toggle("fold");
858
+ detailsBtn.setAttribute("aria-expanded", (!nowFold).toString());
859
+ });
860
 
861
+ host.appendChild(sec);
862
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
 
864
+ function updateStepBadgeAndProgress(step, sec, idx) {
865
+ const nowDone = isStepComplete(step);
866
+ $("#badge_" + step.key).textContent = nowDone ? "Complete βœ“" : "In progress";
867
+ if (nowDone && !sec.classList.contains("done")) {
868
+ sec.classList.add("done");
869
+ /*if (idx === 1) advanceToStep(2); */
870
+ } else if (!nowDone) {
871
+ sec.classList.remove("done");
872
+ }
873
+ renderDots(state.visible);
874
+ updateProgress();
875
+ if ($("#finish").classList.contains("show")) {
876
+ refreshFinishBanner();
877
+ } else if (state.reachedEnd && allStepsComplete()) {
878
+ finish();
879
+ }
880
+ }
881
 
882
+ function expandDetailsForStep(stepNum) {
883
+ if (!stepNum) return;
884
+ $$("#timeline .body").forEach((pane) => pane.classList.add("fold"));
885
+ const current = STEPS[stepNum - 1];
886
+ if (!current) return;
887
+ const pane = document.getElementById("fold_" + current.key);
888
+ if (pane) {
889
+ pane.classList.remove("fold");
890
+ const btn = document.querySelector(`button.toggle[data-fold="${current.key}"]`);
891
+ if (btn) btn.setAttribute("aria-expanded", "true");
892
+ }
893
+ }
 
 
 
894
 
895
+ /* ------------ Dots / progress ------------ */
896
+ function renderDots(visible) {
897
+ const d = $("#dots");
898
+ d.innerHTML = "";
899
+ for (let i = 1; i <= STEPS.length; i++) {
900
+ const stepObj = STEPS[i - 1];
901
+ const stepDone = isStepComplete(stepObj);
902
+ const dot = document.createElement("div");
903
+ dot.className =
904
+ "dot" +
905
+ (i <= visible ? " unlocked" : "") +
906
+ (i === state.step ? " active" : "") +
907
+ (stepDone ? " done" : "");
908
+ dot.textContent = i;
909
+ dot.setAttribute("aria-label", `Step ${i}${stepDone ? " complete" : ""}`);
910
+ if (i <= visible) {
911
+ dot.addEventListener("click", () => {
912
+ state.step = i;
913
  state.subQ = 0;
 
914
  save(true);
915
+ askCurrent();
916
+ scrollToStep(i);
917
+ highlightDot();
918
+ });
919
+ }
920
+ d.appendChild(dot);
921
+ }
922
+ updateProgress();
923
+ }
924
+ function highlightDot() {
925
+ $$("#dots .dot").forEach((el, idx) => {
926
+ el.classList.toggle("active", idx + 1 === state.step);
927
+ });
928
+ }
929
 
930
+ function scrollToStep(n) {
931
+ const el = document.querySelector(`.step:nth-of-type(${n})`);
932
+ if (!el) return;
933
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
934
+ el.animate(
935
+ [
936
+ { outlineColor: "#56b4e9", outlineWidth: "0px" },
937
+ { outlineColor: "#56b4e9", outlineWidth: "6px" },
938
+ { outlineColor: "transparent", outlineWidth: "0px" },
939
+ ],
940
+ { duration: 800 }
941
+ );
942
+ }
943
+
944
+ /* ------------ Flow controller (Yes/No) ------------ */
945
+ const flow = $("#flow"),
946
+ qtxt = $("#qtxt"),
947
+ yesBtn = $("#yesBtn"),
948
+ noBtn = $("#noBtn");
949
+
950
+ function showFlow() { flow.classList.remove("hidden"); }
951
+ function hideFlow() { flow.classList.add("hidden"); }
952
+
953
+ function askCurrent() {
954
+ $("#finish").classList.remove("show");
955
+
956
+ if (state.purpose === "anon" || !state.step) {
957
+ hideFlow();
958
+ return;
959
+ }
960
+
961
+ expandDetailsForStep(state.step);
962
+
963
+ if (state.step === 7) {
964
+ const seq = FLOW[7];
965
+ let idx = state.subQ;
966
+ if (idx >= seq.length) {
967
+ state.step = 8;
968
+ state.subQ = 0;
969
+ renderDots(state.visible);
970
+ askCurrent();
971
+ return;
972
+ }
973
+ const node = seq[idx];
974
+ qtxt.textContent = node.q;
975
+ yesBtn.onclick = () => {
976
+ if (node.yes === "next") {
977
+ state.subQ++;
978
+ askCurrent();
979
+ return;
980
  }
981
+ goto(node.yes);
982
+ };
983
+ noBtn.onclick = () => {
984
+ if (node.no === "next") {
985
+ state.subQ++;
986
+ askCurrent();
987
+ return;
 
 
 
 
 
 
 
 
988
  }
989
+ goto(node.no);
990
+ };
991
+ return;
992
+ }
993
 
994
+ const node = FLOW[state.step];
995
+ if (!node) {
996
+ hideFlow();
997
+ return;
998
+ }
999
+ qtxt.textContent = node.q;
1000
+ yesBtn.onclick = () => goto(node.onYes);
1001
+ noBtn.onclick = () => goto(node.onNo);
1002
+ }
1003
 
1004
+ function goto(dest) {
1005
+ if (dest === "end") {
1006
+ finish();
1007
+ return;
1008
+ }
1009
+ state.step = dest;
1010
+ state.subQ = 0;
1011
+ if (state.step > state.visible) state.visible = state.step;
1012
+ renderDots(state.visible);
1013
+ renderTimeline();
1014
+ askCurrent();
1015
+ scrollToStep(state.step);
1016
+ save(true);
1017
+ }
 
 
 
 
1018
 
1019
+ /* Called when step 1 completes to auto reveal step 2 */
1020
+ function advanceToStep(n) {
1021
+ if (state.visible < n) {
1022
+ state.visible = n;
1023
+ renderDots(state.visible);
1024
+ renderTimeline();
1025
+ scrollToStep(n);
1026
+ }
1027
+ state.step = n;
1028
+ state.subQ = 0;
1029
+ askCurrent();
1030
+ save(true);
1031
+ }
1032
+
1033
+ /* ------------ Helpers ------------ */
1034
+ function itemVisibleForPurpose(it) {
1035
+ return !(it.showFor && !it.showFor.includes(state.purpose));
1036
+ }
1037
+
1038
+ function isStepComplete(step) {
1039
+ const d = state.checks[step.key] || {};
1040
+
1041
+ const checkItem = (it, parentChecked = true) => {
1042
+ if (!itemVisibleForPurpose(it)) return true;
1043
+ if (!parentChecked) return true;
1044
+
1045
+ if (it.req && !d[it.k]) return false;
1046
+
1047
+ if (it.children && d[it.k]) {
1048
+ for (const ch of it.children) {
1049
+ if (!itemVisibleForPurpose(ch)) continue;
1050
+ if (ch.req && !d[ch.k]) return false;
1051
  }
1052
+ }
1053
+ return true;
1054
+ };
1055
+
1056
+ for (const it of step.items) {
1057
+ if (!checkItem(it, true)) return false;
1058
+ }
1059
+ return true;
1060
+ }
1061
+
1062
+ function allStepsComplete() {
1063
+ if (!state.purpose || state.purpose === "anon") return false;
1064
+ return STEPS.every((s) => isStepComplete(s));
1065
+ }
1066
+
1067
+ function updateProgress() {
1068
+ let total = 0, done = 0;
1069
+ STEPS.forEach((s) => {
1070
+ if (state.purpose === "anon" && s.num > 1) return;
1071
+ const d = state.checks[s.key] || {};
1072
+ s.items.forEach((it) => {
1073
+ if (!itemVisibleForPurpose(it)) return;
1074
 
1075
+ const countChild = (ch, parentChecked) => {
1076
+ if (!itemVisibleForPurpose(ch) || !parentChecked) return;
1077
+ if (ch.req) {
1078
+ total++;
1079
+ if (d[ch.k]) done++;
 
 
 
 
 
1080
  }
1081
+ };
1082
 
1083
+ if (it.req) {
1084
+ total++;
1085
+ if (d[it.k]) done++;
 
 
1086
  }
1087
+ if (it.children && d[it.k]) {
1088
+ it.children.forEach((ch) => countChild(ch, true));
 
 
 
1089
  }
1090
+ });
1091
+ });
1092
+ const pct = total ? Math.round((100 * done) / total) : 0;
1093
+ $("#pbar").style.width = pct + "%";
1094
+ }
1095
 
1096
+ /* ------------ Finish ------------ */
1097
+ function finish() {
1098
+ hideFlow();
1099
+ state.reachedEnd = true;
1100
+ save(true);
1101
+ const ok = allStepsComplete();
1102
+ const titleEl = $("#finishTitle");
1103
+ const subEl = $("#finishSub");
1104
+ const confettiHost = $("#confetti");
1105
+ if (ok) {
1106
+ titleEl.textContent = "End πŸŽ‰";
1107
+ subEl.textContent = "All flow checks passed.";
1108
+ confettiHost.innerHTML = "";
1109
+ confetti(24);
1110
+ } else {
1111
+ titleEl.textContent = "End";
1112
+ subEl.textContent = "";
1113
+ confettiHost.innerHTML = "";
1114
+ }
1115
+ $("#finish").classList.add("show");
1116
+ }
1117
+ function refreshFinishBanner() {
1118
+ const ok = allStepsComplete();
1119
+ const titleEl = $("#finishTitle");
1120
+ const subEl = $("#finishSub");
1121
+ const confettiHost = $("#confetti");
1122
+ if (ok) {
1123
+ titleEl.textContent = "End πŸŽ‰";
1124
+ subEl.textContent = "All flow checks passed.";
1125
+ confettiHost.innerHTML = "";
1126
+ confetti(24);
1127
+ } else {
1128
+ titleEl.textContent = "End";
1129
+ subEl.textContent = "";
1130
+ confettiHost.innerHTML = "";
1131
+ }
1132
+ }
1133
+ function confetti(n) {
1134
+ const host = $("#confetti");
1135
+ host.innerHTML = "";
1136
+ for (let i = 0; i < n; i++) {
1137
+ const piece = document.createElement("i");
1138
+ piece.style.setProperty("--dx", Math.random() * 300 - 150 + "px");
1139
+ piece.style.left = 20 + Math.random() * 60 + "%";
1140
+ piece.style.background = i % 2 ? "#0072B2" : "#E69F00";
1141
+ piece.style.animationDelay = Math.random() * 0.4 + "s";
1142
+ host.appendChild(piece);
1143
+ }
1144
+ }
1145
 
1146
+ /* ------------ Save/Load ------------ */
1147
+ function save(quiet = false) {
1148
+ state.savedAt = Date.now();
1149
+ localStorage.setItem(STORAGE, JSON.stringify(state));
1150
+ if (!quiet) updateProgress();
1151
+ }
1152
+ function load() {
1153
+ try {
1154
+ const raw = localStorage.getItem(STORAGE);
1155
+ if (raw) Object.assign(state, JSON.parse(raw));
1156
+ } catch (e) {}
1157
+ }
1158
 
1159
+ /* ------------ Clear / Reset ------------ */
1160
+ function clearAll() {
1161
+ try { localStorage.removeItem(STORAGE); } catch (e) {}
1162
+ state.purpose = null;
1163
+ state.checks = {};
1164
+ state.savedAt = null;
1165
+ state.visible = 0;
1166
+ state.step = 0;
1167
+ state.subQ = 0;
1168
+ state.reachedEnd = false;
1169
+ renderPurposes();
1170
+ renderDots(0);
1171
+ $("#timeline").innerHTML = "";
1172
+ $("#finish").classList.remove("show");
1173
+ $("#anonMsg").classList.add("hidden");
1174
+ hideFlow();
1175
+ updateProgress();
1176
+ }
1177
+
1178
+ /* ------------ Boot ------------ */
1179
+ load();
1180
+ renderPurposes();
1181
+ if (state.purpose && state.purpose !== "anon") {
1182
+ state.visible = state.visible || 1;
1183
+ state.step = state.step || 1;
1184
+ renderDots(state.visible);
1185
+ renderTimeline();
1186
+ showFlow();
1187
+ askCurrent();
1188
+ } else {
1189
+ hideFlow();
1190
+ }
1191
+ updateProgress();
1192
+
1193
+ // Clear button handler
1194
+ document.getElementById("clearBtn").addEventListener("click", () => {
1195
+ const ok = confirm("Clear all saved progress and do a fresh start? This will remove your selections!");
1196
+ if (ok) clearAll();
1197
+ });