Commit 03229fea by PLN (Algolia)

TDAH rebuilt in React on the kit; couple gains share-all, PDF export, and compare view

parent f00c6653
No preview for this file type
import ClarteTdah from '@/components/tdah/ClarteTdah';
export const metadata = {
title: 'Clarté: Adult ADHD Self-Assessment',
description: 'A structured self-assessment of attention-deficit/hyperactivity disorder (ADHD) in adults, built on validated tools: the ASRS v1.1 (WHO), the DSM-5 criteria, and executive functions. Free, private, with a PDF report at the end.',
};
export default function Page() {
return <ClarteTdah lang="en" />;
}
import ClarteTdah from '@/components/tdah/ClarteTdah';
export const metadata = {
title: 'Clarté : Auto-évaluation du TDAH chez l’adulte',
description: 'Auto-évaluation du TDAH chez l’adulte, avec des outils validés : ASRS v1.1 (OMS), critères DSM-5, fonctions exécutives. Gratuit, confidentiel, rapport PDF à la fin.',
};
export default function Page() {
return <ClarteTdah lang="fr" />;
}
......@@ -8,7 +8,7 @@
import { useCallback, useEffect, useState } from 'react';
import { COUPLE_DATA } from './data';
import { decodePayload, storageAvailable } from './engine';
import { decodeAnyPayload, readRecord as readRecordLocal, storageAvailable } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
import HomeView from './HomeView';
......@@ -17,6 +17,8 @@ import QuizView from './QuizView';
import WorksheetView from './WorksheetView';
import RecapView from './RecapView';
import SharedView, { SharedErrorView } from './SharedView';
import SharedAllView from './SharedAllView';
import CompareView from './CompareView';
function questionnaireById(qid) {
var q = null;
......@@ -36,9 +38,11 @@ export default function ClarteCouple() {
setMounted(true);
setStorageOk(storageAvailable());
var hash = typeof location !== 'undefined' ? location.hash : '';
if (hash.lastIndexOf('#p=', 0) === 0) {
var payload = decodePayload(hash.slice(3));
setRoute(payload ? { view: 'shared', payload: payload } : { view: 'sharedError' });
if (hash.lastIndexOf('#p=', 0) === 0 || hash.lastIndexOf('#a=', 0) === 0) {
var decoded = decodeAnyPayload(hash.slice(3));
if (!decoded) setRoute({ view: 'sharedError' });
else if (decoded.kind === 'all') setRoute({ view: 'sharedAll', payload: decoded.payload });
else setRoute({ view: 'shared', payload: decoded.payload });
} else {
setRoute({ view: 'home' });
}
......@@ -49,6 +53,24 @@ export default function ClarteCouple() {
}, []);
const goHome = useCallback(() => setRoute({ view: 'home' }), []);
const goCompare = useCallback((hash) => {
var decoded = decodeAnyPayload(hash);
if (!decoded) { setRoute({ view: 'home', compareError: true }); return; }
if (decoded.kind !== 'one') { setRoute({ view: 'home', compareError: true }); return; }
// leur payload = un questionnaire ; on le compare au même qid localement
var qid = decoded.payload.qid;
var mine = readRecordLocal(qid);
if (!mine || Object.keys(mine.answers || {}).length === 0) {
// rien à comparer côté local : on affiche quand même leur lecture seule
setRoute({ view: 'shared', payload: decoded.payload });
return;
}
setRoute({
view: 'compare', qid: qid,
myRecord: mine,
theirBlock: decoded.payload,
});
}, []);
const goIntro = useCallback((qid) => setRoute({ view: 'intro', qid: qid }), []);
const goRecap = useCallback((qid) => setRoute({ view: 'recap', qid: qid }), []);
const goQuiz = useCallback((qid, pos) => {
......@@ -81,7 +103,16 @@ export default function ClarteCouple() {
);
if (route.view === 'shared') return withBanner(<SharedView payload={route.payload} />);
if (route.view === 'sharedAll') return withBanner(<SharedAllView payload={route.payload} />);
if (route.view === 'sharedError') return withBanner(<SharedErrorView />);
if (route.view === 'compare') return withBanner(
<CompareView
myQid={route.qid}
myRecord={route.myRecord}
theirBlock={route.theirBlock}
onHome={goHome}
/>
);
const q = route.qid ? questionnaireById(route.qid) : null;
......@@ -127,6 +158,7 @@ export default function ClarteCouple() {
goIntro={goIntro}
goQuiz={goQuiz}
goRecap={goRecap}
goCompare={goCompare}
trackWrite={trackWrite}
/>
);
......
'use client';
/* Clarté couple — comparaison à deux. On colle le lien de l'autre : les
réponses se mettent côte à côte, item par item, divergences visibles.
Aucun score de compatibilité (context.md §5 : divergence par item, jamais
un chiffre de couple). Le conseil de l'instrument (debrief verbatim) est
affiché au-dessus du tableau quand il existe ; la règle écrite du
bonheur_conjugal (« discuter les 3 et 4, noter les 0-2 sans débattre »)
colore les lignes en conséquence. Les réponses de l'autre ne s'écrivent
jamais sur cet appareil. */
import { useMemo, useState } from 'react';
import { COUPLE_DATA } from './data';
import { compareAnswers, discussionAdvice, displayTitle } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function CompareView({ myQid, myRecord, theirBlock, onHome }) {
var q = null;
COUPLE_DATA.questionnaires.forEach(function (x) { if (x.id === myQid) q = x; });
if (!q) return null;
var advice = discussionAdvice(myQid);
var rows = useMemo(
function () { return compareAnswers(myQid, myRecord, theirBlock, q); },
[myQid, myRecord, theirBlock]
);
var theirSc = theirBlock && typeof theirBlock.pct === 'number' ? theirBlock.pct : null;
var mySc = myRecord ? computePct(q, myRecord) : null;
return (
<div className={styles.root}>
<h1 className={styles.title}>{strings.compare.title(displayTitle(q.title))}</h1>
<p className={styles.muted}>
{strings.compare.legende(
myRecord ? (myRecord.respondent || strings.compare.moi) : strings.compare.moi,
theirBlock.respondent || strings.compare.autre
)}
</p>
{advice && (
<div className={styles.adviceBand}>
<p>{strings.compare.adviceTitle}</p>
<p className={styles.muted}>
{strings.compare.adviceRule(advice.discussFrom, advice.noteBelow)}
</p>
</div>
)}
<table className={styles.compareTable}>
<thead>
<tr>
<th>{strings.compare.colItem}</th>
<th>{strings.compare.colMine}</th>
<th>{strings.compare.colTheirs}</th>
</tr>
</thead>
<tbody>
{rows.map(function (r) {
var cls = '';
if (advice && r.mine != null && r.theirs != null) {
var mineN = parseInt(r.mine, 10);
var theirsN = parseInt(r.theirs, 10);
if (!isNaN(mineN) && !isNaN(theirsN)) {
if (mineN >= advice.discussFrom || theirsN >= advice.discussFrom) cls = styles.rowDiscuss;
else if (mineN < advice.noteBelow || theirsN < advice.noteBelow) cls = styles.rowNote;
}
}
if (r.divergence && !cls) cls = styles.rowDivergent;
return (
<tr key={r.key} className={cls}>
<td>{r.text}</td>
<td>{r.mine || '—'}</td>
<td>{r.theirs || '—'}</td>
</tr>
);
})}
</tbody>
</table>
{(mySc != null || theirSc != null) && (
<p className={styles.muted}>
{strings.compare.pcts(mySc == null ? null : mySc + ' %', theirSc == null ? null : theirSc + ' %')}
</p>
)}
<p className={styles.muted}>{strings.compare.noScore}</p>
<button type="button" className={styles.btn} onClick={onHome}>{strings.recap.backHome}</button>
</div>
);
}
function computePct(q, st) {
var sum = 0, maxSum = 0;
q.sections.forEach(function (s) {
(s.items || []).forEach(function (it) {
var v = st.answers[String(it.index)];
if (typeof v === 'number') { sum += v; maxSum += 4; }
});
});
return maxSum > 0 ? Math.round((sum / maxSum) * 100) : null;
}
......@@ -18,8 +18,11 @@ import {
firstUnanswered,
readRecord,
readSession,
serializeAllAnswers,
shareUrl,
writeSession,
} from './engine';
import { exportCouplePdf } from './pdf-export';
import { strings } from './strings';
import styles from './couple.module.css';
......@@ -32,9 +35,13 @@ function cardClick(q, goIntro, goQuiz, goRecap) {
else goQuiz(q.id, firstUnanswered(q, st));
}
export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite }) {
export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite, goCompare }) {
const [session, setSession] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [shareAllOpen, setShareAllOpen] = useState(false);
const [compareLink, setCompareLink] = useState('');
const [compareError, setCompareError] = useState(false);
const [pdfDone, setPdfDone] = useState(false);
useEffect(() => {
setSession(readSession());
......@@ -107,6 +114,41 @@ export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite }) {
<p className={styles.muted}>{strings.home.disclosure}</p>
<p className={styles.muted}>{strings.home.closing}</p>
<div className={styles.together}>
<h2>{strings.home.togetherTitle}</h2>
<p className={styles.muted}>{strings.home.togetherIntro}</p>
<div className={styles.footlinks}>
<button type="button" className={styles.linkbtn} onClick={function () { setShareAllOpen(!shareAllOpen); }}>
{strings.home.shareAllBtn}
</button>
{' · '}
<button type="button" className={styles.linkbtn} onClick={async function () {
var ok = await exportCouplePdf();
if (ok) { setPdfDone(true); setTimeout(function () { setPdfDone(false); }, 2500); }
}}>
{pdfDone ? strings.home.pdfDone : strings.home.pdfBtn}
</button>
</div>
{shareAllOpen && <ShareAllPanel />}
<div className={styles.compareRow}>
<label htmlFor="compare-link">{strings.compare.pasteLabel}</label>
<input
id="compare-link"
value={compareLink}
placeholder={strings.compare.pasteHint}
onChange={function (e) { setCompareLink(e.target.value); setCompareError(false); }}
/>
<button type="button" className={styles.btn} onClick={function () {
var raw = compareLink.trim();
var hash = raw.indexOf('#p=') !== -1 ? raw.slice(raw.indexOf('#p=') + 3)
: raw.indexOf('#a=') !== -1 ? raw.slice(raw.indexOf('#a=') + 3) : '';
if (!hash) { setCompareError(true); return; }
goCompare(hash);
}}>{strings.compare.pasteBtn}</button>
</div>
{compareError && <p className={styles.muted}>{strings.compare.pasteError}</p>}
</div>
<footer className={styles.pagefoot}>
<p>{strings.home.footLine}</p>
<p className={styles.footlinks}>
......@@ -142,3 +184,45 @@ export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite }) {
</div>
);
}
/* Lien global : toutes les réponses, encodées dans le fragment (#a=),
même avertissement que le lien mono-questionnaire. */
function ShareAllPanel() {
const [copied, setCopied] = useState(false);
var payload = serializeAllAnswers(readSession().respondent_label);
var url = shareUrl(payload, 'a');
function flashCopied() {
setCopied(true);
setTimeout(function () { setCopied(false); }, 2000);
}
function copy() {
var ok = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(flashCopied, function () {});
ok = true;
}
} catch (e) {}
if (!ok) {
try {
var inp = document.getElementById('share-all-input');
if (inp) { inp.select(); if (document.execCommand('copy')) flashCopied(); }
} catch (e2) {}
}
}
return (
<div className={styles.sharePanel}>
<p>{strings.recap.share.warning}</p>
<div className={styles.shareRow}>
<input id="share-all-input" readOnly value={url} aria-label={strings.recap.share.inputAria}
onFocus={function (e) { e.target.select(); }} />
<button type="button" className={styles.btn} onClick={copy}>
{copied ? strings.recap.share.copied : strings.recap.share.copy}
</button>
</div>
<p className={styles.muted}>{strings.home.shareAllNote}</p>
</div>
);
}
'use client';
/* Clarté couple — vue partagée globale (#a=) : les réponses de TOUT le
parcours sur une page, pour une lecture de thérapeute sans répétition de
huit liens. Lecture seule : rien n'est écrit sur l'appareil du destinataire. */
import { useMemo } from 'react';
import { COUPLE_DATA } from './data';
import { displayTitle } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function SharedAllView({ payload }) {
const blocks = useMemo(() => {
return (payload.questionnaires || []).map(function (b) {
var q = null;
COUPLE_DATA.questionnaires.forEach(function (x) { if (x.id === b.qid) q = x; });
return { block: b, q: q };
});
}, [payload]);
const totalAnswered = (payload.questionnaires || []).reduce(function (a, b) {
return a + (b.answered || []).length;
}, 0);
const created = payload.created ? new Date(payload.created) : null;
return (
<div className={styles.root}>
<h1 className={styles.title}>{strings.sharedAll.title}</h1>
<p className={styles.muted}>
{strings.sharedAll.by(payload.respondent || strings.home.whoLabel)}
{created ? ' · ' + created.toLocaleDateString('fr-FR') : ''}
{' · ' + strings.sharedAll.totalLine(totalAnswered)}
</p>
<p className={styles.muted}>{strings.shared.readOnly}</p>
{blocks.map(function ({ block, q }) {
var items = q ? q.sections.reduce(function (a, s) { return a + (s.items || []).length; }, 0) : 0;
return (
<section key={block.qid} className={styles.sharedAllBlock}>
<h2>{displayTitle(block.title)}</h2>
{(block.answered || []).length === 0 ? (
<p className={styles.muted}>{strings.sharedAll.empty}</p>
) : (
<>
{block.pct != null && (
<p className={styles.sharedPct}>
{strings.sharedAll.scoreLine(block.pct)}
</p>
)}
<table className={styles.recapTable}>
<tbody>
{(block.answered || []).map(function (e, i) {
return (
<tr key={e.i}>
<td>{e.i}</td>
<td className={e.v && typeof e.v === 'object' ? styles.recapFree : null}>
{e.l || (e.v && typeof e.v === 'object' ? e.v.text : String(e.v))}
</td>
</tr>
);
})}
</tbody>
</table>
{(block.answered || []).length < items && (
<p className={styles.muted}>
{strings.sharedAll.partial(block.answered.length, items)}
</p>
)}
</>
)}
</section>
);
})}
<p className={styles.muted}>{strings.shared.foot}</p>
</div>
);
}
......@@ -412,3 +412,22 @@
.muted + .muted { margin-top: var(--sp-3, 1rem); }
.navGroup { display: flex; gap: .6rem; }
/* À deux : partage global, PDF, comparaison. */
.together { margin-top: var(--sp-5); padding-top: var(--sp-3); border-top: 1px solid var(--line); }
.together h2 { font-size: var(--fs-body); margin: 0 0 var(--sp-1); }
.compareRow { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: baseline; margin-top: var(--sp-3); }
.compareRow label { font-size: var(--fs-small); }
.compareRow input { flex: 1 1 16rem; font: inherit; font-size: var(--fs-small); color: var(--ink); background: #fff; border: 1px solid var(--line-strong); border-radius: 8px; padding: .45rem .6rem; min-height: 44px; }
.compareRow input:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.sharedAllBlock { margin: var(--sp-4) 0; }
.sharedAllBlock h2 { font-size: var(--fs-body); color: var(--teal-text); margin: 0 0 var(--sp-1); }
.sharedPct { font-weight: 700; color: var(--teal-text); }
.compareTable { width: 100%; border-collapse: collapse; font-size: var(--fs-small); margin: var(--sp-3) 0; }
.compareTable th { text-align: left; border-bottom: 1px solid var(--line-strong); padding: var(--sp-1) var(--sp-2) var(--sp-1) 0; font-size: var(--fs-caption); color: var(--ink-faint); }
.compareTable td { border-bottom: 1px solid var(--line); padding: var(--sp-1) var(--sp-2) var(--sp-1) 0; vertical-align: top; }
.rowDiscuss { background: var(--teal-soft); }
.rowNote { color: var(--ink-faint); }
.rowDivergent { background: #fff; font-weight: 700; }
.adviceBand { background: var(--teal-soft); border-radius: var(--radius); padding: var(--sp-2) var(--sp-3); margin: var(--sp-3) 0; }
.adviceBand p { margin: 0; font-size: var(--fs-small); }
......@@ -196,9 +196,10 @@ export function decodePayload(s) {
}
}
export function shareUrl(payload) {
if (typeof location === 'undefined') return '';
return location.href.split('#')[0] + '#p=' + answersToBase64url(payload);
export function shareUrl(payload, fragKey) {
if (typeof window === 'undefined') return '';
var key = fragKey || 'p';
return window.location.origin + window.location.pathname + '#' + key + '=' + answersToBase64url(payload);
}
/* ---------- stockage ----------
......@@ -318,3 +319,80 @@ export function downloadExport() {
URL.revokeObjectURL(url);
}, 0);
}
/* ---------- partage global, export PDF, comparaison à deux ---------- */
/* Sérialise TOUS les questionnaires en un seul payload (#a=). Chaque bloc
réutilise serializeAnswers ; les questionnaires sans réponse sont inclus
vides (le thérapeute voit ce qui reste à remplir). */
export function serializeAllAnswers(respondentLabel) {
var readAll = readAllRecords();
return {
v: 1,
kind: 'all',
respondent: respondentLabel,
created: new Date().toISOString(),
questionnaires: COUPLE_DATA.questionnaires.map(function (q) {
var block = serializeAnswers(q.id, readAll[q.id], respondentLabel);
return block || { qid: q.id, title: q.title, respondent: respondentLabel, answered: [] };
}),
};
}
function readAllRecords() {
var out = {};
COUPLE_DATA.questionnaires.forEach(function (q) { out[q.id] = readRecord(q.id); });
return out;
}
/* Décode les deux formes de lien : mono-questionnaire (#p=) et global (#a=). */
export function decodeAnyPayload(s) {
var p = decodePayload(s);
if (p) return { kind: 'one', payload: p };
try {
var b64 = s.replace(/-/g, '+').replace(/_/g, '/');
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var o = JSON.parse(utf8Decode(bytes));
if (o && typeof o === 'object' && o.kind === 'all' && Array.isArray(o.questionnaires)) {
return { kind: 'all', payload: o };
}
} catch (e) {}
return null;
}
/* Le conseil de discussion propre à l'instrument (le debrief du support) :
pour bonheur_conjugal la règle du clinicien est explicite — discuter les
3 et 4, noter les 0-2 sans débattre. On la porte telle quelle ; les
instruments sans règle écrite n'en inventent pas. */
export function discussionAdvice(qid) {
if (qid !== 'bonheur_conjugal') return null;
return {
discussFrom: 3, // items cotés 3-4 par l'un ou l'autre : matière à dire
noteBelow: 3, // items cotés 0-2 : à noter, à adapter, pas à débattre
};
}
/* Regroupe les réponses des deux partenaires, item par item. Retourne des
lignes {key, text, mine, theirs, labelA, labelB, divergence} — jamais un
score de compatibilité (context.md §5 : divergence par item, rien d'autre). */
export function compareAnswers(qid, myRecord, theirBlock, q) {
var rows = [];
var theirMap = {};
(theirBlock && theirBlock.answered || []).forEach(function (e) { theirMap[e.i] = e; });
allItems(q).forEach(function (r) {
var mineV = myRecord && myRecord.answers ? myRecord.answers[r.key] : undefined;
var theirEntry = theirMap[r.key];
var rowsVal = {
key: r.key,
text: r.item.text,
mine: mineV !== undefined ? optionLabel(q, r.section, mineV) : null,
theirs: theirEntry ? (theirEntry.l || '') : null,
};
// divergence : les deux ont répondu et l'étiquette diffère
rowsVal.divergence = rowsVal.mine != null && rowsVal.theirs != null && rowsVal.mine !== rowsVal.theirs;
rows.push(rowsVal);
});
return rows;
}
/* Clarté couple — export PDF des questionnaires remplis (jspdf). Un document,
huit blocs : titre verbatim, instructions, items numérotés avec la réponse
cochée (étiquette), score quand l'instrument en a un. Le destinataire lit
l'équivalent du support papier, rempli. Aucune donnée ne quitte l'appareil
avant l'enregistrement du fichier par la personne elle-même. */
import { COUPLE_DATA } from './data';
import { allItems, readRecord, readSession, optionLabel } from './engine';
export async function exportCouplePdf() {
let JsPDF;
try { ({ jsPDF: JsPDF } = await import('jspdf')); }
catch (e) { return false; }
var doc = new JsPDF({ unit: 'pt', format: 'a4' });
var W = doc.internal.pageSize.getWidth();
var H = doc.internal.pageSize.getHeight();
var y = 50;
function ensure(h) {
if (y + h > H - 60) { foot(); doc.addPage(); y = 50; }
}
function foot() {
var i = doc.internal.getNumberOfPages();
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(130);
doc.text('Clarté · Espace couple · document personnel · page ' + i, 50, H - 30);
}
function text(s, size, bold, indent) {
doc.setFont('helvetica', bold ? 'bold' : 'normal');
doc.setFontSize(size || 10);
doc.setTextColor(40);
var lines = doc.splitTextToSize(s, W - 100 - (indent || 0));
lines.forEach(function (line) { ensure(14); doc.text(line, 50 + (indent || 0), y); y += 14; });
}
var session = readSession();
text('Clarté · Espace couple', 16, true);
text('Réponses de : ' + (session.respondent_label || 'moi') + ' · ' + new Date().toLocaleDateString('fr-FR'), 10);
y += 10;
COUPLE_DATA.questionnaires.forEach(function (q) {
var st = readRecord(q.id);
var items = allItems(q);
var answered = 0;
items.forEach(function (r) { if (st && st.answers[r.key] !== undefined) answered++; });
ensure(30);
y += 8;
text(q.title, 13, true);
if (q.instructions) text(q.instructions, 8);
text(answered + ' / ' + items.length + ' répondues', 8);
y += 4;
items.forEach(function (r, i) {
var v = st ? st.answers[r.key] : undefined;
var label = v !== undefined ? optionLabel(q, r.section, v) : '(non répondu)';
text((i + 1) + '. ' + r.item.text, 9, false, 8);
text('→ ' + label, 9, true, 20);
});
// score de l'instrument, quand il en a un et qu'il est complet
if (q.scoring && q.scoring.method === 'sum_all_items' && st) {
var sum = 0, maxSum = 0, n = 0;
items.forEach(function (r) {
var v = st.answers[r.key];
if (typeof v === 'number') { sum += v; maxSum += 4; n++; }
});
if (n === items.length && maxSum > 0) {
text('Score : ' + Math.round((sum / maxSum) * 100) + ' %', 10, true);
}
}
});
foot();
doc.save('clarte-couple-' + new Date().toISOString().slice(0, 10) + '.pdf');
return true;
}
......@@ -26,6 +26,14 @@ export const strings = {
cardQuestions: (total) => total + ' questions',
cardWorksheet: (total) => total + ' écrits à compléter',
cardPartial: (n, total) => 'En cours · ' + n + ' sur ' + total,
togetherTitle: 'À deux, et avec votre praticienne',
togetherIntro:
'Un lien avec toutes vos réponses, un document PDF à apporter en séance, ou la comparaison côte à côte avec la personne que vous aimez.',
shareAllBtn: 'Créer un lien avec toutes mes réponses',
shareAllNote:
'Ce lien regroupe tout le parcours sur une seule page, en lecture seule pour qui l’ouvre. Même prudence que pour un lien simple.',
pdfBtn: 'Exporter tout en PDF (documents remplis)',
pdfDone: 'PDF téléchargé',
},
intro: {
......@@ -110,4 +118,37 @@ export const strings = {
'Lien incomplet ou altéré. Demandez à la personne qui l’a envoyé d’en créer un nouveau.',
backHome: 'Retour à l’accueil',
},
sharedAll: {
title: 'Réponses partagées : tout le parcours',
by: (respondent) => 'Partagé par ' + respondent,
totalLine: (n) =>
n === 1 ? '1 réponse au total' : n + ' réponses au total',
empty: 'Aucune réponse à ce questionnaire pour l’instant.',
scoreLine: (pct) => 'Score de l’instrument : ' + pct + ' %',
partial: (n, total) => n + ' réponses sur ' + total + ' : le questionnaire est encore en cours.',
},
compare: {
pasteLabel: 'Comparer nos réponses',
pasteHint: 'Collez ici le lien que l’autre personne vous a envoyé.',
pasteAria: 'Lien de partage à comparer',
pasteBtn: 'Comparer',
pasteError: 'Ce lien n’a pas pu être lu. Demandez un nouveau lien de partage.',
title: (qTitle) => 'À deux : ' + qTitle,
legende: (mine, theirs) => 'Colonne du milieu : ' + mine + ' (cet appareil). Colonne de droite : ' + theirs + '.',
colItem: 'Question',
colMine: 'Moi',
colTheirs: 'L’autre',
moi: 'cet appareil',
autre: 'l’autre',
adviceTitle: 'Le conseil du support d’origine pour en parler :',
adviceRule: (from, below) =>
'Les réponses à ' + from + ' ou 4 sont faites pour être dites ; les réponses sous ' + below +
' se notent pour adapter son comportement, sans en faire un débat.',
pcts: (mine, theirs) =>
'Scores de l’instrument : ' + (mine || '—') + ' (moi) et ' + (theirs || '—') + (theirs ? '' : '') + '.',
noScore:
'Il n’y a pas de score de compatibilité ici, et c’est voulu : ce qui se regarde, c’est chaque question, une par une.',
},
};
'use client';
/* Clarté TDAH — racine. Écran unique, une question à la fois (spec §6),
flux = liste de nœuds reconstruite selon l'état de la Partie B (spec §4).
Persistance : contrat clarte:v1 gelé (kit README) — {responses,
currentSection, pos, partBSkipped, lastMilestone?} ; les enregistrements
vanilla existants ({responses, currentSection}) fusionnent sans perte. */
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ASRS_PART_A, ASRS_PART_B, DIVA_INATTENTION, DIVA_HYPERACTIVITY,
DIVA_CHILDHOOD, DIVA_IMPAIRMENT, EXEC_CLUSTERS, PART_B_IDS, NOTES,
SIX_MONTHS_NOTE_FIRST, TOTAL_QUESTIONS,
} from './data';
import { dict as dictFr } from './i18n/fr';
import { dict as dictEn } from './i18n/en';
import { readState, writeState, clearState, storageAvailable, partAShadedCount, partBAnswered, scope } from './engine';
import HomeView from './HomeView';
import QuizScreen from './QuizScreen';
import RecapView from './RecapView';
import ResultsView from './ResultsView';
import GlossaryView from './GlossaryView';
import styles from './tdah.module.css';
function questionnaireNodes(partBSkipped, responses) {
/* Nœuds de flux : intro → questions (+ porte) → ponts → récapitulatif.
La porte n'apparaît que si la Partie A est sous le seuil ET que la
Partie B n'est ni passée ni commencée (spec §4). */
const nodes = [];
const pushQ = (list, block, extra) =>
list.forEach((q, i) => nodes.push({ type: 'question', q, block, indexInBlock: i, ...extra }));
nodes.push({ type: 'intro', id: 'asrs' });
pushQ(ASRS_PART_A, 'asrsA', { scale: 'asrs' });
if (partAShadedCount(responses) < 4 && !partBSkipped && !partBAnswered(responses)) {
nodes.push({ type: 'gate' });
}
if (!partBSkipped) pushQ(ASRS_PART_B, 'asrsB', { scale: 'asrs' });
nodes.push({ type: 'intro', id: 'dsm' });
pushQ(DIVA_INATTENTION, 'dsmA1', { binary: true });
nodes.push({ type: 'bridge', id: 'a2' });
pushQ(DIVA_HYPERACTIVITY, 'dsmA2', { binary: true });
nodes.push({ type: 'intro', id: 'enfance' });
pushQ(DIVA_CHILDHOOD, 'dsmB', { binary: true });
nodes.push({ type: 'intro', id: 'retentissement' });
pushQ(DIVA_IMPAIRMENT, 'dsmCD', { binary: true });
nodes.push({ type: 'bridge', id: 'brown' });
EXEC_CLUSTERS.forEach((c) => pushQ(c.questions, 'exec', { scale: 'ef', cluster: c }));
nodes.push({ type: 'recap' });
return nodes;
}
export default function ClarteTdah({ lang }) {
const t = lang === 'en' ? dictEn : dictFr;
const [mounted, setMounted] = useState(false);
const [state, setState] = useState(null); // {responses, currentSection, pos, partBSkipped, lastMilestone}
const [storageOk, setStorageOk] = useState(true);
useEffect(() => {
setMounted(true);
setStorageOk(storageAvailable());
const saved = readState();
setState(saved
? { responses: saved.responses || {}, pos: typeof saved.pos === 'number' ? saved.pos : 0,
partBSkipped: !!saved.partBSkipped, lastMilestone: saved.lastMilestone || null,
currentSection: saved.currentSection || 'home' }
: { responses: {}, pos: 0, partBSkipped: false, lastMilestone: null, currentSection: 'home' });
}, []);
const persist = useCallback((next) => {
setState(next);
return writeState(next);
}, []);
const goHome = useCallback(() => {
setState((s) => { const n = { ...s, currentSection: 'home' }; writeState(n); return n; });
setRouteLocal({ view: 'home' });
}, []);
const [route, setRouteLocal] = useState({ view: 'home' });
const nodes = useMemo(
() => (state ? questionnaireNodes(state.partBSkipped, state.responses) : []),
[state && state.partBSkipped, state && !!partBAnswered(state ? state.responses : {}), state && state.responses === state.responses]
);
const commitAnswer = useCallback((qid, value) => {
setState((s) => {
const responses = { ...s.responses, [qid]: value };
const next = { ...s, responses };
// milestones (deck §12) : franchissement → affiché sur l'écran suivant
const answered = Object.keys(responses).length;
const sc = scope(next.partBSkipped);
const inAsrsB = PART_B_IDS.indexOf(qid) !== -1;
const finishedAsrs = answered === (next.partBSkipped ? ASRS_PART_A.length : ASRS_PART_A.length + ASRS_PART_B.length);
const finishedDsm = answered === ASRS_PART_A.length + ASRS_PART_B.length + DIVA_INATTENTION.length + DIVA_HYPERACTIVITY.length + DIVA_CHILDHOOD.length + DIVA_IMPAIRMENT.length;
let milestone = null;
if (answered === sc) milestone = 'all.done';
else if (!inAsrsB && finishedAsrs) milestone = 'asrs.done';
else if (finishedDsm) milestone = 'dsm.done';
else if (answered === Math.floor(sc / 2)) milestone = 'half';
if (milestone && milestone !== s.lastMilestone) next.lastMilestone = milestone;
writeState(next);
return next;
});
}, []);
if (!mounted || !state) return <div className={styles.root} />;
const currentSection = route.view === 'home' ? 'home' : route.view;
if (route.view === 'home') {
return (
<HomeView
t={t} state={state} styles={styles} storageOk={storageOk}
onStart={(pos) => { persist({ ...state, currentSection: 'flow' }); setRouteLocal({ view: 'flow', pos }); }}
onGlossary={() => setRouteLocal({ view: 'glossary' })}
/>
);
}
if (route.view === 'glossary') {
return <GlossaryView t={t} styles={styles} onBack={() => setRouteLocal({ view: 'home' })} />;
}
if (route.view === 'results') {
return (
<ResultsView
t={t} styles={styles} responses={state.responses} lang={lang}
onHome={goHome}
onReview={() => setRouteLocal({ view: 'flow', pos: firstQuestionNode(nodes, state.responses) })}
onRestart={() => { clearState(); setState({ responses: {}, pos: 0, partBSkipped: false, lastMilestone: null, currentSection: 'home' }); setRouteLocal({ view: 'home' }); }}
/>
);
}
// flux : question / porte / récapitulatif
const pos = Math.max(0, Math.min(route.pos ?? state.pos ?? 0, nodes.length - 1));
const node = nodes[pos];
if (node.type === 'recap') {
return (
<RecapView
t={t} styles={styles} state={state} nodes={nodes}
onJump={(i) => setRouteLocal({ view: 'flow', pos: i })}
onResults={() => { persist({ ...state, currentSection: 'results' }); setRouteLocal({ view: 'results' }); }}
onHome={goHome}
/>
);
}
if (node.type === 'intro' || node.type === 'bridge') {
return <Interstitial node={node} t={t} styles={styles} onNext={() => setRouteLocal({ view: 'flow', pos: pos + 1 })} answeredCount={Object.keys(state.responses).length} total={scope(state.partBSkipped)} lang={lang} />;
}
if (node.type === 'gate') {
return (
<GateView
t={t} styles={styles}
onAnswer={() => setRouteLocal({ view: 'flow', pos: pos + 1 })}
onSkip={() => { persist({ ...state, partBSkipped: true }); setRouteLocal({ view: 'flow', pos: pos + 1 }); }}
/>
);
}
return (
<QuizScreen
key={node.q.id}
node={node} t={t} styles={styles}
value={state.responses[node.q.id]}
answered={Object.keys(state.responses).length}
total={scope(state.partBSkipped)}
milestone={state.lastMilestone}
showMilestone={state.lastMilestone && state.lastMilestoneAt === pos}
onPick={(v) => {
commitAnswer(node.q.id, v);
// auto-avance ~350 ms (spec §6.3), jamais sur le dernier écran de bloc
const nextType = nodes[pos + 1] && nodes[pos + 1].type;
if (nextType === 'question') {
setTimeout(() => setRouteLocal((r) => (r.view === 'flow' ? { view: 'flow', pos: pos + 1 } : r)), 350);
} else {
setRouteLocal({ view: 'flow', pos: pos + 1 });
}
}}
onNext={() => setRouteLocal({ view: 'flow', pos: Math.min(pos + 1, nodes.length - 1) })}
onPrev={() => setRouteLocal({ view: 'flow', pos: Math.max(pos - 1, 0) })}
/>
);
}
function firstQuestionNode(nodes, responses) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].type === 'question' && responses[nodes[i].q.id] === undefined) return i;
}
return nodes.length - 1;
}
/* Écrans intro / pont (deck §12 : intro.asrs/dsm/enfance/retentissement,
bridge.a2/brown) — un geste, verbatim sous le CTA quand c'est long. */
function Interstitial({ node, t, styles, onNext, answeredCount, total, lang }) {
const key = node.type === 'intro' ? 'intro.' + node.id : 'bridge.' + node.id;
const h = t[key + '.h'];
const p = t[key + '.p'];
return (
<div className={styles.interstitial}>
<h1>{h}</h1>
<p className={styles.muted}>
{answeredCount} {answeredCount === 1 ? (lang === 'en' ? 'answered' : 'répondu') : (lang === 'en' ? 'answered' : 'répondues')} · {total} {lang === 'en' ? 'in the whole flow' : 'dans tout le parcours'}
</p>
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={onNext}>
{t['intro.continue.btn']}
</button>
<p>{p}</p>
</div>
);
}
/* Porte de la Partie B (spec §4, copy deck gate.*). Décision réversible :
le récapitulatif propose de reprendre la partie. */
function GateView({ t, styles, onAnswer, onSkip }) {
return (
<div className={styles.interstitial}>
<h2>{t['gate.title']}</h2>
<p>{t['gate.body']}</p>
<div className={styles.gateActions}>
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={onAnswer}>{t['gate.answer.btn']}</button>
<button className={styles.btn} onClick={onSkip}>{t['gate.skip.btn']}</button>
</div>
</div>
);
}
export { questionnaireNodes, firstQuestionNode, TOTAL_QUESTIONS };
'use client';
/* Clarté TDAH — glossaire (spec §7.5) : la seule porte du vocabulaire,
dix entrées verbatim du deck §14. */
export default function GlossaryView({ t, styles, onBack }) {
const keys = ['tdah', 'asrs', 'dsm5', 'inattention', 'hyperactivite', 'impulsivite',
'fonctions.executives', 'memoire.travail', 'retentissement', 'depistage'];
return (
<div className={styles.root}>
<h1>{t['glossary.title']}</h1>
<dl>
{keys.map((k) => (
<div key={k} className={styles.glossEntry}>
<dt>{t['glossary.' + k].split('.')[0]}</dt>
<dd>{t['glossary.' + k]}</dd>
</div>
))}
</dl>
<button className={styles.btn} onClick={onBack}>{t['header.logo.h1']}</button>
</div>
);
}
'use client';
/* Clarté TDAH — accueil (spec §5) : h2 de but, trois cartes outils, consentement
bloquant, carte de reprise sans culpabilité, avertissement six points verbatim
du deck §4, ligne de durée honnête (li5), sauvegarde démontrable (§9.1). */
import { useState } from 'react';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import { TOTAL_QUESTIONS } from './data';
import { progressLine, progressFull } from './QuizScreen';
export default function HomeView({ t, state, styles, storageOk, onStart, onGlossary }) {
const [consent, setConsent] = useState(!!state.consented);
const answered = Object.keys(state.responses).length;
const hasProgress = answered > 0;
const pos = typeof state.pos === 'number' ? state.pos : 0;
const start = () => {
if (!consent) return;
try { const s = JSON.parse(JSON.stringify(state)); s.consented = true; localStorage.setItem('clarte:v1', JSON.stringify(s)); } catch (e) {}
onStart(hasProgress ? pos : 0);
};
return (
<div className={styles.root}>
<h1>{t['header.logo.h1']}<span className={styles.tagline}>{t['header.subtitle']}</span></h1>
<h2>{t['home.h2']}</h2>
<p>{t['home.intro.p']}</p>
<div className={styles.cards}>
<div className={styles.card}>
<h4>{t['home.card.asrs.h4']}</h4>
<p>{t['home.card.asrs.p']}</p>
</div>
<div className={styles.card}>
<h4>{t['home.card.dsm.h4']}</h4>
<p>{t['home.card.dsm.p']}</p>
</div>
<div className={styles.card}>
<h4>{t['home.card.ef.h4']}</h4>
<p>{t['home.card.ef.p']}</p>
</div>
</div>
<div className={styles.warning}>
<h4>{t['home.warning.h4']}</h4>
<ul>
<li><strong>{t['home.warning.li1']}</strong></li>
<li>{t['home.warning.li2']}</li>
<li>{t['home.warning.li3']}</li>
<li>{t['home.warning.li4']}</li>
<li>{t['home.warning.li5']}</li>
<li>{t['home.warning.li6']}</li>
</ul>
</div>
{hasProgress && (
<div className={styles.resume}>
<p>{t['resume.count'].replace('{n}', String(answered)).replace('{total}', String(TOTAL_QUESTIONS)).replace(/question\(s\)/, answered > 1 ? 'questions' : 'question')}</p>
<div className={styles.nav}>
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={start}>{t['resume.primary']}</button>
</div>
</div>
)}
{!hasProgress && (
<div className={styles.consent}>
<label>
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span>{t['home.consent.label']}</span>
</label>
<button className={`${styles.primaryBtn} ${styles.btn}`} disabled={!consent} onClick={start}>
{t['home.start.btn']}
</button>
</div>
)}
<p className={styles.muted}>{t['home.storage.line']}</p>
<button className={styles.linkbtn} onClick={onGlossary}>{t['glossary.link']}</button>
</div>
);
}
'use client';
/* Clarté TDAH — écran question : QuestionCard (kit), pilules ou binaire Oui/Non
(même état sélectionné, pas de corail/sauge, spec §6.2), note repliée
« Pourquoi cette question ? » (deck §7.4, ≤ 1 par écran), clavier : chiffres
(useDigitKeys), flèches, Entrée = avancer. Auto-avance portée par le parent. */
import { useEffect, useRef, useState } from 'react';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import QuestionCard from '@/components/clarte/QuestionCard';
import OptionPills from '@/components/clarte/OptionPills';
import useDigitKeys from '@/components/clarte/useDigitKeys';
import { NOTES, SIX_MONTHS_NOTE_FIRST } from './data';
import styles from './tdah.module.css';
const NBSP = ' ';
export default function QuizScreen({ node, t, styles, value, answered, total, milestone, showMilestone, onPick, onNext, onPrev }) {
const { q, block, scale, binary, cluster, indexInBlock } = node;
const isBinary = !!binary;
const optionCount = isBinary ? 2 : scale === 'asrs' ? 5 : 4;
const ribbon = (() => {
switch (block) {
case 'asrsA': return t['asrs.h2'] + NBSP + '· ' + t['asrs.partA.h3'];
case 'asrsB': return t['asrs.h2'] + NBSP + '· ' + t['asrs.partB.h3'];
case 'dsmA1': return t['diva.h2'] + NBSP + '· ' + t['diva.A1.h3'];
case 'dsmA2': return t['diva.h2'] + NBSP + '· ' + t['diva.A2.h3'];
case 'dsmB': return t['diva.h2'] + NBSP + '· ' + t['diva.B.h3'];
case 'dsmCD': return t['diva.h2'] + NBSP + '· ' + t['diva.CD.h3'];
case 'exec': return t['executive.h2'] + NBSP + '· ' + (cluster ? t[cluster.nameKey] : '');
default: return '';
}
})();
useDigitKeys(optionCount, (i) => {
if (isBinary) onPick(i === 0 ? 1 : 0);
else onPick(i);
});
useEffect(function arrowsAndEnter() {
function onKey(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (event.target && event.target.isContentEditable)) return;
if (event.key === 'ArrowRight' || event.key === 'Enter') { event.preventDefault(); onNext(); }
else if (event.key === 'ArrowLeft') { event.preventDefault(); onPrev(); }
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cardRef = useRef(null);
useEffect(function focusQuestion() {
const node2 = cardRef.current && cardRef.current.querySelector('h2');
if (node2) { node2.setAttribute('tabindex', '-1'); node2.focus({ preventScroll: true }); }
}, []);
const noteKey = NOTES[q.id] || null;
const sixMonths = q.id === SIX_MONTHS_NOTE_FIRST;
const options = isBinary
? [{ value: 1, label: t['diva.binary.yes'] }, { value: 0, label: t['diva.binary.no'] }]
: (scale === 'asrs'
? [0, 1, 2, 3, 4].map((i) => ({ value: i, label: t['asrs.option.' + i] }))
: [0, 1, 2, 3].map((i) => ({ value: i, label: t['executive.ef_options.' + i] })));
return (
<div>
<ProgressHeader
answered={answered}
total={total}
milestone={showMilestone && milestone ? t['milestone.' + milestone] : null}
labels={{
answeredLine: (n, m) => progressLine(t, n, m),
fullLine: (n) => progressFull(t, n),
}}
/>
<div ref={cardRef} className={styles.quizCard}>
<QuestionCard
ribbon={ribbon + (sixMonths ? NBSP + '· ' + t['intro.sixMonthsSuffix'] : '')}
subtitle={block === 'exec' ? t[cluster.descKey] : null}
question={t[q.t]}
footer={
<div className={styles.nav}>
<button className={styles.linkbtn} onClick={onPrev}>{t['intro.back'] || '←'}</button>
<button className={styles.linkbtn} onClick={onNext}>{t['intro.skip.btn']}</button>
</div>
}
>
<OptionPills options={options} value={value != null ? value : null} onChange={onPick} indexHint />
{noteKey && (
<details className={styles.note}>
<summary>{t['note.label']}</summary>
<p>{t[noteKey]}</p>
</details>
)}
</QuestionCard>
</div>
</div>
);
}
function progressLine(t, n, m) {
// deck §12 progress.answered : « {n} question(s) répondue(s) · il en reste {m} »
// — le (s) est résolu par le code : 0 et 1 au singulier (deck : « 0 question
// répondue · il en reste 70 »), {n} et {m} substitués.
let s = t['progress.answered'];
s = s.replace(/\{n\}/g, String(n)).replace(/\{m\}/g, String(m));
s = s.replace(/question\(s\)/g, n > 1 ? 'questions' : 'question');
s = s.replace(/répondue\(s\)/g, n > 1 ? 'répondues' : 'répondue');
s = s.replace(/question\(s\)/g, n > 1 ? 'questions' : 'question');
return s;
}
function progressFull(t, n) {
let s = t['progress.answered'];
s = s.replace(/\{n\}/g, String(n)).replace(/\{total\}/g, String(n)).replace(/\{m\}/g, '0');
s = s.replace(/question\(s\)/g, n > 1 ? 'questions' : 'question');
s = s.replace(/répondue\(s\)/g, n > 1 ? 'répondues' : 'répondue');
s = s.replace(/\s*·\s*(il en reste\s*0|0 to go)/, '');
return s;
}
export { progressLine, progressFull };
'use client';
/* Clarté TDAH — récapitulatif (spec §6.4) : les 70 items groupés par bloc,
puces « non répondu » neutres, saut au clic, porte de complétion : tout est
répondu → « Voir mes résultats → » ; sinon compte + aller à la première
manquante / passer les restantes (hors périmètre, comme un saut Partie B). */
import { useMemo } from 'react';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import {
ASRS_PART_A, ASRS_PART_B, DIVA_INATTENTION, DIVA_HYPERACTIVITY,
DIVA_CHILDHOOD, DIVA_IMPAIRMENT, EXEC_CLUSTERS,
} from './data';
import { progressLine } from './QuizScreen';
export default function RecapView({ t, styles, state, nodes, onJump, onResults, onHome }) {
const responses = state.responses;
const groups = useMemo(() => ([
{ label: t['asrs.h2'], qs: [...ASRS_PART_A, ...ASRS_PART_B] },
{ label: t['diva.A1.h3'], qs: DIVA_INATTENTION },
{ label: t['diva.A2.h3'], qs: DIVA_HYPERACTIVITY },
{ label: t['diva.B.h3'], qs: DIVA_CHILDHOOD },
{ label: t['diva.CD.h3'], qs: DIVA_IMPAIRMENT },
...EXEC_CLUSTERS.map((c) => ({ label: t[c.nameKey], qs: c.questions })),
]), [t]);
const nodeIndex = useMemo(() => {
const m = {};
nodes.forEach((n, i) => { if (n.type === 'question') m[n.q.id] = i; });
return m;
}, [nodes]);
const answered = Object.keys(responses).length;
const unanswered = TOTAL_Q() - answered;
const firstMissing = nodes.findIndex((n) => n.type === 'question' && responses[n.q.id] === undefined);
const optionLabel = (qid) => {
const v = responses[qid];
if (v === undefined) return null;
if (DIVA_INATTENTION.concat(DIVA_HYPERACTIVITY, DIVA_CHILDHOOD, DIVA_IMPAIRMENT).some((q) => q.id === qid)) {
return v === 1 ? t['diva.binary.yes'] : t['diva.binary.no'];
}
return v >= 4 ? t['asrs.option.' + v] : (t['asrs.option.' + v] || t['executive.ef_options.' + v]);
};
const skipRemaining = () => {
const next = { ...state, responses: { ...state.responses } };
nodes.forEach((n) => {
if (n.type === 'question' && next.responses[n.q.id] === undefined) next.responses[n.q.id] = 0;
});
// note : un saut Partie B est réversible au récapitulatif ; ici les items
// passés sont mis à 0 comme le `|| 0` du POC (spéc §6.4, byte-identique)
onJump(nodes.length - 1);
};
return (
<div className={styles.root}>
<ProgressHeader
answered={answered}
total={TOTAL_Q()}
labels={{ answeredLine: (n, m) => progressLine(t, n, m), fullLine: (n) => progressLine(t, n, 0) }}
/>
<h1>{t['recap.title']}</h1>
{groups.map((g) => (
<div key={g.label} className={styles.recapGroup}>
<h3>{g.label}</h3>
<ul>
{g.qs.map((q) => {
const label = optionLabel(q.id);
const idx = nodeIndex[q.id];
return (
<li key={q.id}>
<button type="button" className={styles.recapRow} onClick={() => onJump(idx)}>
<span className={styles.recapText}>{t[q.t]}</span>
<span className={label ? styles.recapChip : styles.recapChipEmpty}>
{label || t['recap.unanswered.chip']}
</span>
</button>
</li>
);
})}
</ul>
</div>
))}
<div className={styles.nav}>
<button className={styles.btn} onClick={onHome}>{t['recap.backHome'] || t['resume.secondary']}</button>
{unanswered === 0 ? (
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={onResults}>{t['recap.complete.btn']}</button>
) : (
<>
<span className={styles.muted}>{t['recap.missing.count'].replace('{n}', String(unanswered)).replace(/question\(s\)/, unanswered > 1 ? 'questions' : 'question')}</span>
<button className={styles.btn} onClick={() => onJump(firstMissing)}>{t['recap.missing.go']}</button>
<button className={styles.btn} onClick={skipRemaining}>{t['recap.missing.skip']}</button>
</>
)}
</div>
</div>
);
}
function TOTAL_Q() {
return 70;
}
'use client';
/* Clarté TDAH — résultats (spec §8) : rappel non-diagnostique, synthèse à
trois puces (variantes n du deck), trois cartes, « Comprendre votre profil »
×3, « Pour la consultation » avec phrase copiable, PDF, annexe. */
import { useMemo, useState } from 'react';
import { calculateResults } from './engine';
import { EXEC_CLUSTERS, DIVA_IMPAIRMENT } from './data';
import { buildReport } from './pdf';
export default function ResultsView({ t, styles, responses, lang, onHome, onReview, onRestart }) {
const r = useMemo(() => calculateResults(responses), [responses]);
const [copied, setCopied] = useState(false);
const n = r.global.positiveIndicators;
const summaryText = n === 0 ? t['results.summary.text.empty'] : n >= 2 ? t['results.summary.text.high'] : t['results.summary.text.low'];
const summary = summaryText.replace(/\{n\}/g, String(n));
const domainLabel = (key) => t['diva.domain.' + key];
const severityLabel = (s) => t['results.severity.' + s];
const copySentence = () => {
const text = t['consult.sentence'];
const done = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); };
try {
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; }
} catch (e) {}
try {
const inp = document.createElement('textarea');
inp.value = text; document.body.appendChild(inp); inp.select();
if (document.execCommand('copy')) done();
document.body.removeChild(inp);
} catch (e) {}
};
return (
<div className={styles.root}>
<p className={styles.warningInline}><strong>{t['results.warning.p']}</strong></p>
<h2>{t['results.summary.h2']}</h2>
<p className={styles.summaryText}>{summary}</p>
{r.asrs.screeningPositive && <p>{t['results.summary.bullet.asrs']}</p>}
{(r.diva.meetsInattention || r.diva.meetsHyperactivity) && (
<p>{t['results.summary.bullet.dsm'].replace('{presentation}', t['results.presentation.' + r.diva.presentation])}</p>
)}
{r.executive.severity !== 'low' && (
<p>{t['results.summary.bullet.ef'].replace('{niveau}', severityLabel(r.executive.severity))}</p>
)}
<h2>{t['understand.title']}</h2>
<p>{t['understand.asrs.p']}</p>
<p>{t['understand.dsm.p']}</p>
<p>{t['understand.ef.p']}</p>
<div className={styles.card}>
<h3>{t['results.card1.h3']}</h3>
<p>{t['results.card1.status']
.replace('{atteint}', r.asrs.screeningPositive ? 'atteint' : 'non atteint')
.replace('{met}', r.asrs.screeningPositive ? 'met' : 'not met')
.replace('{non atteint}', 'non atteint').replace('{not met}', 'not met')
.replace('{x}', String(r.asrs.partAShaded))}</p>
<p>{t['results.card1.label.partA']} : {r.asrs.partAScore}/24</p>
<p>{t['results.card1.label.partB']}</p>
<p>{t['results.card1.inattention'].replace('{n}', String(r.asrs.inattention))}</p>
<p>{t['results.card1.hyperactivity'].replace('{n}', String(r.asrs.hyperactivity))}</p>
</div>
<div className={styles.card}>
<h3>{t['results.card2.h3']}</h3>
<p>{t['results.presentation.' + r.diva.presentation]}</p>
<p>{t['results.card2.label.A1']} : {r.diva.inattentionCount}/9</p>
<p>{t['results.card2.label.A2']} : {r.diva.hyperactivityCount}/9</p>
<p>{t['results.card2.label.B']} : {r.diva.childhoodCount}/5</p>
<p>{t['results.card2.label.impairment']} : {r.diva.impairmentDomainKeys.map(domainLabel).join(', ') || '—'}</p>
</div>
<div className={styles.card}>
<h3>{t['results.card3.h3']}</h3>
<p>{t['results.card3.status']
.replace('{niveau}', severityLabel(r.executive.severity)).replace('{level}', severityLabel(r.executive.severity))
.replace('{p}', String(r.executive.percentage))}</p>
{EXEC_CLUSTERS.map((c) => (
<p key={c.key}>{t[c.nameKey]} : {r.executive.clusterScores[c.key].percentage}%</p>
))}
</div>
<h2>{t['consult.title']}</h2>
<p>{t['consult.bring.p']}</p>
<p><strong>{t['consult.sentence.label']}</strong></p>
<div className={styles.consultRow}>
<p>{t['consult.sentence']}</p>
<button className={styles.btn} onClick={copySentence}>{copied ? '✓' : t['recap.share.copy']}</button>
</div>
<p><strong>{t['consult.questions.h']}</strong></p>
<ul>
<li>{t['consult.q1']}</li>
<li>{t['consult.q2']}</li>
<li>{t['consult.q3']}</li>
</ul>
<div className={styles.nav}>
<button className={styles.btn} onClick={onHome}>{t['recap.backHome']}</button>
<button className={styles.btn} onClick={onReview}>{t['recap.review.fromresults']}</button>
<button className={styles.btn} onClick={() => buildReport(t, responses, r, lang)}>{t['results.download.btn']}</button>
<button className={styles.btn} onClick={onRestart}>{t['results.restart.btn']}</button>
</div>
</div>
);
}
/* Clarté TDAH — structure de l'instrument. Les TEXTES ne vivent pas ici :
chaque question référence une clé de dictionnaire (i18n/fr.js, i18n/en.js,
générés depuis les decks copy-fr.md / copy-en.md). Les seuils et l'ordre
sont l'instrument : intouchables (deck §11.3). */
export const ASRS_PART_A = [
{ id: 'asrs_1', t: 'asrs.q1', threshold: 2 },
{ id: 'asrs_2', t: 'asrs.q2', threshold: 2 },
{ id: 'asrs_3', t: 'asrs.q3', threshold: 2 },
{ id: 'asrs_4', t: 'asrs.q4', threshold: 3 },
{ id: 'asrs_5', t: 'asrs.q5', threshold: 3 },
{ id: 'asrs_6', t: 'asrs.q6', threshold: 3 },
];
export const ASRS_PART_B = [
{ id: 'asrs_7' }, { id: 'asrs_8' }, { id: 'asrs_9' }, { id: 'asrs_10' },
{ id: 'asrs_11' }, { id: 'asrs_12' }, { id: 'asrs_13' }, { id: 'asrs_14' },
{ id: 'asrs_15' }, { id: 'asrs_16' }, { id: 'asrs_17' }, { id: 'asrs_18' },
].map((q, i) => ({ ...q, t: 'asrs.q' + (i + 7) }));
/* Sous-échelles indicatives (deck §11.2) : items d'inattention au sens de
l'instrument ; le reste compte en hyperactivité/impulsivité. */
export const ASRS_INATTENTION_NUMS = [1, 2, 3, 4, 7, 8, 9, 10, 11];
export const DIVA_INATTENTION = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => ({
id: 'diva_ia_' + i, t: 'diva.ia_' + i, binary: true,
}));
export const DIVA_HYPERACTIVITY = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => ({
id: 'diva_hi_' + i, t: 'diva.hi_' + i, binary: true,
}));
export const DIVA_CHILDHOOD = [1, 2, 3, 4, 5].map((i) => ({
id: 'diva_child_' + i, t: 'diva.child_' + i, binary: true,
}));
export const DIVA_IMPAIRMENT = [1, 2, 3, 4, 5].map((i) => ({
id: 'diva_imp_' + i, t: 'diva.imp_' + i, binary: true,
domainKey: ['work', 'family', 'social', 'leisure', 'selfesteem'][i - 1],
}));
export const EXEC_CLUSTERS = [
{ key: 'activation', nameKey: 'executive.cluster.activation.name', descKey: 'executive.cluster.activation.desc', prefix: 'ef_act' },
{ key: 'focus', nameKey: 'executive.cluster.focus.name', descKey: 'executive.cluster.focus.desc', prefix: 'ef_foc' },
{ key: 'effort', nameKey: 'executive.cluster.effort.name', descKey: 'executive.cluster.effort.desc', prefix: 'ef_eff' },
{ key: 'emotion', nameKey: 'executive.cluster.emotion.name', descKey: 'executive.cluster.emotion.desc', prefix: 'ef_emo' },
{ key: 'memory', nameKey: 'executive.cluster.memory.name', descKey: 'executive.cluster.memory.desc', prefix: 'ef_mem' },
{ key: 'action', nameKey: 'executive.cluster.action.name', descKey: 'executive.cluster.action.desc', prefix: 'ef_act2' },
].map((c) => ({
...c,
questions: [1, 2, 3, 4].map((i) => ({ id: c.prefix + '_' + i, t: c.prefix + '_' + i })),
}));
export const ASRS_IDS = [...ASRS_PART_A, ...ASRS_PART_B].map((q) => q.id);
export const DIVA_IDS = [
...DIVA_INATTENTION, ...DIVA_HYPERACTIVITY, ...DIVA_CHILDHOOD, ...DIVA_IMPAIRMENT,
].map((q) => q.id);
export const EXECUTIVE_IDS = EXEC_CLUSTERS.flatMap((c) => c.questions.map((q) => q.id));
export const ALL_QUESTION_IDS = [...ASRS_IDS, ...DIVA_IDS, ...EXECUTIVE_IDS];
export const TOTAL_QUESTIONS = ALL_QUESTION_IDS.length; /* 70 */
export const PART_B_IDS = ASRS_PART_B.map((q) => q.id);
/* Note pour chaque question, repliée (deck §7.4 : huit « Pourquoi cette question ? »).
null = pas de note ; la règle « ≤ 1 par écran » est respectée par construction. */
export const NOTES = {
asrs_5: 'note.asrs.adult.hyper',
asrs_6: 'note.asrs.adult.hyper',
asrs_4: 'note.asrs.avoidance',
ef_act_1: 'note.ef.activation',
ef_mem_1: 'note.ef.memory',
ef_emo_1: 'note.ef.emotion',
diva_ia_1: 'note.dsm.inattention',
diva_hi_1: 'note.dsm.impulsivity',
};
/* Suffixe « 6 derniers mois » du ruban, sur la première question à échelle de
fréquence seulement (deck §7.4 note 6) — la note s'affiche alors sous elle. */
export const SIX_MONTHS_NOTE_FIRST = 'asrs_1';
/* Clarté TDAH — moteur : persistance (contrat clarte:v1, gelé) et scoring
(port octet-identique du POC vanilla : seuils 2/2/2/3/3/3, dépistage à
≥ 4 cases grisées ; DSM 5/9 ; EF bandes 40/66 ; aucun score Partie B). */
import {
ASRS_PART_A, ASRS_PART_B, ASRS_INATTENTION_NUMS, DIVA_INATTENTION,
DIVA_HYPERACTIVITY, DIVA_CHILDHOOD, DIVA_IMPAIRMENT, EXEC_CLUSTERS,
TOTAL_QUESTIONS, PART_B_IDS,
} from './data';
export const STORAGE_KEY = 'clarte:v1';
export function storageAvailable() {
try {
const k = STORAGE_KEY + ':probe';
localStorage.setItem(k, '1');
localStorage.removeItem(k);
return true;
} catch (e) { return false; }
}
export function readState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const v = JSON.parse(raw);
if (!v || typeof v !== 'object' || typeof v.responses !== 'object' || v.responses === null) return null;
return v;
} catch (e) { return null; }
}
export function writeState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return true;
} catch (e) { return false; }
}
export function clearState() {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
}
export function answeredCount(responses) {
return Object.keys(responses || {}).filter((k) => PART_B_IDS.indexOf(k) === -1 || true).length;
}
/* ---------- scoring : port du POC, byte-identique ---------- */
export function scoreASRS(responses) {
let partAScore = 0, partAShaded = 0, partBScore = 0;
let inattention = 0, hyperactivity = 0;
const inattentionQs = ASRS_INATTENTION_NUMS;
ASRS_PART_A.forEach((q) => {
const val = responses[q.id] || 0;
partAScore += val;
if (val >= q.threshold) partAShaded++;
const num = parseInt(q.id.split('_')[1]);
if (inattentionQs.indexOf(num) !== -1) inattention += val;
else hyperactivity += val;
});
ASRS_PART_B.forEach((q) => {
const val = responses[q.id] || 0;
partBScore += val;
const num = parseInt(q.id.split('_')[1]);
if (inattentionQs.indexOf(num) !== -1) inattention += val;
else hyperactivity += val;
});
const screeningPositive = partAShaded >= 4;
return { partAScore, partAShaded, partBScore, inattention, hyperactivity, screeningPositive };
}
export function scoreDIVA(responses) {
let inattentionCount = 0, hyperactivityCount = 0, childhoodCount = 0;
const impairmentDomainKeys = [];
DIVA_INATTENTION.forEach((q) => { if (responses[q.id] === 1) inattentionCount++; });
DIVA_HYPERACTIVITY.forEach((q) => { if (responses[q.id] === 1) hyperactivityCount++; });
DIVA_CHILDHOOD.forEach((q) => { if (responses[q.id] === 1) childhoodCount++; });
DIVA_IMPAIRMENT.forEach((q) => { if (responses[q.id] === 1) impairmentDomainKeys.push(q.domainKey); });
const meetsInattention = inattentionCount >= 5;
const meetsHyperactivity = hyperactivityCount >= 5;
return {
inattentionCount, hyperactivityCount, childhoodCount,
childhoodPositive: childhoodCount >= 2,
impairmentDomainKeys,
meetsInattention, meetsHyperactivity,
presentation: meetsInattention && meetsHyperactivity ? 'combined'
: meetsInattention ? 'inattentive'
: meetsHyperactivity ? 'hyperactive' : 'below',
};
}
export function scoreExecutive(responses) {
const clusterScores = {};
let total = 0, maxTotal = 0;
EXEC_CLUSTERS.forEach((cluster) => {
let score = 0;
cluster.questions.forEach((q) => { score += responses[q.id] || 0; });
const max = cluster.questions.length * 3;
clusterScores[cluster.key] = { score, max, percentage: Math.round((score / max) * 100) };
total += score;
maxTotal += max;
});
const percentage = Math.round((total / maxTotal) * 100);
const severity = percentage >= 66 ? 'high' : percentage >= 40 ? 'moderate' : 'low';
const impaired = EXEC_CLUSTERS.filter((c) => clusterScores[c.key].percentage >= 50).map((c) => c.key);
return { clusterScores, total, maxTotal, percentage, severity, impaired };
}
export function calculateResults(responses) {
const asrs = scoreASRS(responses);
const diva = scoreDIVA(responses);
const executive = scoreExecutive(responses);
let positiveIndicators = 0;
if (asrs.screeningPositive) positiveIndicators++;
if (diva.meetsInattention || diva.meetsHyperactivity) positiveIndicators++;
if (executive.severity !== 'low') positiveIndicators++;
return { asrs, diva, executive, global: { positiveIndicators } };
}
/* ---------- Partie A grisée → porte de la Partie B (spec §4) ---------- */
export function partAShadedCount(responses) {
let shaded = 0;
ASRS_PART_A.forEach((q) => {
if ((responses[q.id] || 0) >= q.threshold) shaded++;
});
return shaded;
}
export function partBAnswered(responses) {
return PART_B_IDS.some((id) => responses[id] !== undefined);
}
/* ---------- périmètre (scope) : 70, ou 58 si Partie B passée ---------- */
export function scope(partBSkipped) {
return partBSkipped ? TOTAL_QUESTIONS - PART_B_IDS.length : TOTAL_QUESTIONS;
}
/* Clarté TDAH — compte rendu PDF (deck §9) : structure du POC conservée
(titre + avertissement, synthèse, cartes par instrument, annexe réponses
détaillées, références, pied de page non-diagnostique sur chaque page). */
import { ASRS_PART_A, ASRS_PART_B, DIVA_INATTENTION, DIVA_HYPERACTIVITY, DIVA_CHILDHOOD, DIVA_IMPAIRMENT, EXEC_CLUSTERS } from './data';
export async function buildReport(t, responses, r, lang) {
let JsPDF;
try { ({ jsPDF: JsPDF } = await import('jspdf')); }
catch (e) { console.error('jspdf unavailable', e); return; }
const doc = new JsPDF({ unit: 'pt', format: 'a4' });
const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight();
let y = 50;
const page = () => 1;
function ensure(h) {
if (y + h > H - 50) { footer(); doc.addPage(); y = 50; }
}
function footer() {
const i = doc.internal.getNumberOfPages();
doc.setFontSize(8); doc.setTextColor(130);
doc.text(t['pdf.pagefooter'].replace('{i}', String(i)).replace('{n}', String(i)), 50, H - 30);
}
function text(s, size = 10, bold = false, x = 50) {
doc.setFont('helvetica', bold ? 'bold' : 'normal');
doc.setFontSize(size);
const lines = doc.splitTextToSize(s, W - x - 50);
lines.forEach((line) => { ensure(14); doc.text(line, x, y); y += 14; });
}
text(t['pdf.title'], 16, true); y += 6;
text(t['pdf.warning.p'], 9); y += 10;
text(t['pdf.summary.h'], 13, true);
text(t['pdf.summary.text'] === undefined ? '' : synthese(t, r), 10); y += 10;
text(t['pdf.section1.h'], 12, true);
text(t['results.card1.status'].replace('{atteint|non atteint}', r.asrs.screeningPositive ? 'atteint' : 'non atteint').replace('{x}', String(r.asrs.partAShaded)), 10);
text(t['pdf.section1.scores'], 9); y += 8;
text(t['pdf.section2.h'], 12, true);
text((t['pdf.section2.presentation'] || 'Présentation : {presentation}').replace('{presentation}', t['results.presentation.' + r.diva.presentation]), 10);
DIVA_CHILDHOOD.forEach(() => {});
text((t['pdf.section2.childhood'] || 'Enfance (critère B) : {c}').replace('{confirmé | non confirmé}', r.diva.childhoodPositive ? 'confirmé' : 'non confirmé').replace('{confirmed | not confirmed}', r.diva.childhoodPositive ? 'confirmed' : 'not confirmed'), 10);
text((t['pdf.section2.impairment'] || 'Retentissement : {n} domaine(s)').replace('{n}', String(r.diva.impairmentDomainKeys.length)), 10);
y += 8;
text(t['pdf.section3.h'], 12, true);
text((t['pdf.section3.level'] || 'Niveau global : {level} ({p}%)').replace('{level}', t['results.severity.' + r.executive.severity]).replace('{p}', String(r.executive.percentage)), 10);
y += 8;
text(t['pdf.annexe.h'], 12, true);
const all = [
...ASRS_PART_A.map((q) => [q.id, q.t, 'asrs.option.' + (responses[q.id] !== undefined ? responses[q.id] : null)]),
...ASRS_PART_B.map((q) => [q.id, q.t, 'asrs.option.' + (responses[q.id] !== undefined ? responses[q.id] : null)]),
...DIVA_INATTENTION, ...DIVA_HYPERACTIVITY, ...DIVA_CHILDHOOD, ...DIVA_IMPAIRMENT,
].map((x) => (Array.isArray(x) ? x : [x.id, x.t, null]));
EXEC_CLUSTERS.forEach((c) => c.questions.forEach((q) => all.push([q.id, q.t, null])));
all.forEach(([id, tkey, optKey]) => {
const v = responses[id];
const answer = v === undefined
? t['pdf.unanswered']
: optKey && optKey.endsWith('null') ? String(v)
: v === 1 && DIVA_INATTENTION.concat(DIVA_HYPERACTIVITY, DIVA_CHILDHOOD, DIVA_IMPAIRMENT).some((q) => q.id === id) ? t['diva.binary.yes']
: v === 0 && DIVA_INATTENTION.concat(DIVA_HYPERACTIVITY, DIVA_CHILDHOOD, DIVA_IMPAIRMENT).some((q) => q.id === id) ? t['diva.binary.no']
: t['asrs.option.' + v] || t['executive.ef_options.' + v] || String(v);
text('• ' + t[tkey] + ' — ' + answer, 9);
});
text(t['pdf.refs.h'], 12, true);
text(t['pdf.refs.items'] || 'Kessler RC, et al. (2005). Psychological Medicine, 35(2), 245–256. • American Psychiatric Association (2013). DSM-5. • Kooij JJS, et al. (2010). DIVA 2.0. • Brown TE (2013). A New Understanding of ADHD. Routledge.', 8);
footer();
doc.save(lang === 'en' ? 'adhd-self-assessment-report.pdf' : 'compte-rendu-tdah.pdf');
function synthese(t, r) {
const n = r.global.positiveIndicators;
const base = n === 0 ? t['results.summary.text.empty'] : n >= 2 ? t['results.summary.text.high'] : t['results.summary.text.low'];
return base.replace(/\{n\}/g, String(n));
}
}
/* Clarté TDAH — app shell. Les tokens vivent globalement (styles/clarte-tokens.css). */
.root { max-width: 40rem; margin: 0 auto; padding: var(--sp-4) var(--sp-3) var(--sp-6); font-family: var(--font-clarte); background: var(--paper); color: var(--ink); line-height: 1.65; }
.root h1 { font-size: var(--fs-h1); line-height: 1.3; }
.root h1 .tagline { display: block; font-size: var(--fs-small); font-weight: 400; color: var(--ink-faint); }
.root h2 { font-size: 1.25rem; margin: var(--sp-4) 0 var(--sp-2); }
.root p { max-width: var(--measure-prose); }
.muted { color: var(--ink-faint); font-size: var(--fs-small); }
.cards { display: grid; gap: var(--sp-2); margin: var(--sp-3) 0; }
.card { background: #fff; border: 1px solid var(--line-strong); border-radius: var(--radius); padding: var(--sp-3); }
.card h4 { margin: 0 0 var(--sp-1); font-size: var(--fs-body); }
.card p { margin: 0; font-size: var(--fs-small); color: var(--ink); }
.warning { background: #fff; border: 1px solid var(--line-strong); border-radius: var(--radius); padding: var(--sp-3); margin: var(--sp-3) 0; }
.warning h4 { margin: 0 0 var(--sp-2); font-size: var(--fs-body); }
.warning ul { margin: 0; padding-left: 1.1rem; }
.warning li { margin: var(--sp-1) 0; font-size: var(--fs-small); }
.btn { border: 1px solid var(--line-strong); background: #fff; border-radius: var(--radius); padding: .6rem 1.1rem; font: inherit; color: var(--ink); cursor: pointer; }
.btn:hover { border-color: var(--teal); }
.btn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.btn:disabled { background: var(--paper); color: var(--ink-faint); border-color: var(--line); cursor: default; }
.primaryBtn { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
.primaryBtn:hover { background: var(--teal-hover); }
.primaryBtn:disabled { background: var(--teal); color: #fff; opacity: 1; border-color: var(--line-strong); }
.linkbtn { background: none; border: none; color: var(--teal-text); text-decoration: underline; cursor: pointer; font: inherit; padding: 0; }
.linkbtn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.nav { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); margin-top: var(--sp-4); flex-wrap: wrap; }
.consent { margin: var(--sp-4) 0; }
.consent label { display: flex; gap: .6rem; align-items: baseline; font-size: var(--fs-small); }
.consent button { margin-top: var(--sp-3); }
.resume { margin: var(--sp-4) 0; padding: var(--sp-3); background: var(--teal-soft); border-radius: var(--radius); }
.quizCard h2:focus { outline: 2px solid var(--teal); outline-offset: 4px; border-radius: 4px; }
.note { margin-top: var(--sp-3); }
.note summary { color: var(--ink-faint); font-size: var(--fs-small); cursor: pointer; }
.note p { font-size: var(--fs-small); color: var(--ink-faint); }
.interstitial { max-width: 34rem; margin: var(--sp-6) auto; }
.gateActions { display: flex; gap: var(--sp-2); margin: var(--sp-3) 0; flex-wrap: wrap; }
.recapGroup h3 { font-size: var(--fs-body); margin: var(--sp-3) 0 var(--sp-1); color: var(--teal-text); }
.recapGroup ul { list-style: none; padding: 0; margin: 0; }
.recapRow { width: 100%; display: flex; justify-content: space-between; gap: var(--sp-2); background: none; border: none; border-bottom: 1px solid var(--line); padding: var(--sp-1) 0; font: inherit; text-align: left; cursor: pointer; }
.recapRow:hover { border-bottom-color: var(--teal); }
.recapText { font-size: var(--fs-small); }
.recapChip { font-size: var(--fs-caption); color: var(--teal-text); white-space: nowrap; }
.recapChipEmpty { font-size: var(--fs-caption); color: var(--ink-faint); white-space: nowrap; }
.summaryText { font-size: var(--fs-question); }
.warningInline strong { color: var(--danger); }
.consultRow { display: flex; gap: var(--sp-2); align-items: baseline; flex-wrap: wrap; }
.glossEntry { margin: var(--sp-3) 0; }
.glossEntry dt { font-weight: 700; }
.glossEntry dd { margin: 0; font-size: var(--fs-small); color: var(--ink); }
@media (max-width: 480px) {
.root { padding: var(--sp-3) var(--sp-2) var(--sp-5); }
}
......@@ -28,6 +28,7 @@
"framer-motion": "^13.1.1",
"gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29",
"jspdf": "^4.2.1",
"marked": "^15.0.12",
"next": "^15.3.0",
"p5": "1.11.3",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "Clarté — Auto-Évaluation TDAH",
"short_name": "Clarté TDAH",
"lang": "fr",
"start_url": ".",
"display": "standalone",
"background_color": "#FAF8F5",
"theme_color": "#5B8A8A"
}
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Main gradient: warm cream to soft teal -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FAF8F5"/>
<stop offset="30%" style="stop-color:#F0EDE8"/>
<stop offset="70%" style="stop-color:#E8F0F0"/>
<stop offset="100%" style="stop-color:#D8E8E8"/>
</linearGradient>
<!-- Accent blob gradients -->
<radialGradient id="blob1" cx="20%" cy="30%" r="40%">
<stop offset="0%" style="stop-color:#A89BBF;stop-opacity:0.15"/>
<stop offset="100%" style="stop-color:#A89BBF;stop-opacity:0"/>
</radialGradient>
<radialGradient id="blob2" cx="80%" cy="70%" r="35%">
<stop offset="0%" style="stop-color:#D4A27F;stop-opacity:0.12"/>
<stop offset="100%" style="stop-color:#D4A27F;stop-opacity:0"/>
</radialGradient>
<radialGradient id="blob3" cx="60%" cy="20%" r="30%">
<stop offset="0%" style="stop-color:#5B8A8A;stop-opacity:0.08"/>
<stop offset="100%" style="stop-color:#5B8A8A;stop-opacity:0"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bgGradient)"/>
<!-- Soft blobs -->
<ellipse cx="240" cy="190" rx="300" ry="250" fill="url(#blob1)"/>
<ellipse cx="960" cy="440" rx="280" ry="220" fill="url(#blob2)"/>
<ellipse cx="720" cy="126" rx="240" ry="180" fill="url(#blob3)"/>
<!-- Decorative circle/logo -->
<circle cx="600" cy="200" r="60" fill="#5B8A8A" opacity="0.9"/>
<circle cx="600" cy="200" r="45" fill="none" stroke="#FAF8F5" stroke-width="3"/>
<circle cx="600" cy="200" r="8" fill="#FAF8F5"/>
<!-- Title -->
<text x="600" y="340" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="72" font-weight="700" fill="#3A3632">
Clarté
</text>
<!-- Subtitle -->
<text x="600" y="410" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="32" fill="#5B8A8A">
Auto-Évaluation TDAH Adulte
</text>
<!-- Tagline -->
<text x="600" y="480" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="22" fill="#6B6560">
Outils scientifiquement validés • ASRS • DSM-5 • Fonctions Exécutives
</text>
<!-- Bottom accent line -->
<rect x="400" y="530" width="400" height="4" rx="2" fill="#5B8A8A" opacity="0.6"/>
</svg>
/* Cache-first: la page et ses données chargent hors réseau; les réponses
vivent dans localStorage et ne passent jamais par le réseau. */
var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2',
'./fonts/AtkinsonHyperlegible-Bold.woff2',
'./fonts/AtkinsonHyperlegible-Italic.woff2'];
self.addEventListener('install', function (e) {
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); }));
});
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k !== CACHE; })
.map(function (k) { return caches.delete(k); }));
}));
});
self.addEventListener('fetch', function (e) {
if (e.request.method !== 'GET') return;
e.respondWith(caches.match(e.request, { ignoreSearch: true })
.then(function (hit) { return hit || fetch(e.request); }));
});
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "Clarté — Auto-Évaluation TDAH",
"short_name": "Clarté TDAH",
"lang": "fr",
"start_url": ".",
"display": "standalone",
"background_color": "#FAF8F5",
"theme_color": "#5B8A8A"
}
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Main gradient: warm cream to soft teal -->
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FAF8F5"/>
<stop offset="30%" style="stop-color:#F0EDE8"/>
<stop offset="70%" style="stop-color:#E8F0F0"/>
<stop offset="100%" style="stop-color:#D8E8E8"/>
</linearGradient>
<!-- Accent blob gradients -->
<radialGradient id="blob1" cx="20%" cy="30%" r="40%">
<stop offset="0%" style="stop-color:#A89BBF;stop-opacity:0.15"/>
<stop offset="100%" style="stop-color:#A89BBF;stop-opacity:0"/>
</radialGradient>
<radialGradient id="blob2" cx="80%" cy="70%" r="35%">
<stop offset="0%" style="stop-color:#D4A27F;stop-opacity:0.12"/>
<stop offset="100%" style="stop-color:#D4A27F;stop-opacity:0"/>
</radialGradient>
<radialGradient id="blob3" cx="60%" cy="20%" r="30%">
<stop offset="0%" style="stop-color:#5B8A8A;stop-opacity:0.08"/>
<stop offset="100%" style="stop-color:#5B8A8A;stop-opacity:0"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bgGradient)"/>
<!-- Soft blobs -->
<ellipse cx="240" cy="190" rx="300" ry="250" fill="url(#blob1)"/>
<ellipse cx="960" cy="440" rx="280" ry="220" fill="url(#blob2)"/>
<ellipse cx="720" cy="126" rx="240" ry="180" fill="url(#blob3)"/>
<!-- Decorative circle/logo -->
<circle cx="600" cy="200" r="60" fill="#5B8A8A" opacity="0.9"/>
<circle cx="600" cy="200" r="45" fill="none" stroke="#FAF8F5" stroke-width="3"/>
<circle cx="600" cy="200" r="8" fill="#FAF8F5"/>
<!-- Title -->
<text x="600" y="340" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="72" font-weight="700" fill="#3A3632">
Clarté
</text>
<!-- Subtitle -->
<text x="600" y="410" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="32" fill="#5B8A8A">
Auto-Évaluation TDAH Adulte
</text>
<!-- Tagline -->
<text x="600" y="480" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="22" fill="#6B6560">
Outils scientifiquement validés • ASRS • DSM-5 • Fonctions Exécutives
</text>
<!-- Bottom accent line -->
<rect x="400" y="530" width="400" height="4" rx="2" fill="#5B8A8A" opacity="0.6"/>
</svg>
/* Cache-first: la page et ses données chargent hors réseau; les réponses
vivent dans localStorage et ne passent jamais par le réseau. */
var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2',
'./fonts/AtkinsonHyperlegible-Bold.woff2',
'./fonts/AtkinsonHyperlegible-Italic.woff2'];
self.addEventListener('install', function (e) {
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); }));
});
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k !== CACHE; })
.map(function (k) { return caches.delete(k); }));
}));
});
self.addEventListener('fetch', function (e) {
if (e.request.method !== 'GET') return;
e.respondWith(caches.match(e.request, { ignoreSearch: true })
.then(function (hit) { return hit || fetch(e.request); }));
});
......@@ -26,3 +26,9 @@ jest.mock('next/dynamic', () => {
return null;
};
});
// jsdom (Node 20) ne fournit pas TextEncoder/TextDecoder, requis par
// answersToBase64url / decodeAnyPayload.
const { TextEncoder: TE, TextDecoder: TD } = require('util');
if (typeof global.TextEncoder === 'undefined') global.TextEncoder = TE;
if (typeof global.TextDecoder === 'undefined') global.TextDecoder = TD;
/* Clarté couple — partage global (#a=), comparaison à deux, conseil d'origine. */
import { COUPLE_DATA } from '@/components/couple/data';
import {
serializeAllAnswers, decodeAnyPayload, discussionAdvice, compareAnswers,
answersToBase64url,
} from '@/components/couple/engine';
const LS = 'couple:v1:q:';
function Q(id) { return COUPLE_DATA.questionnaires.find((q) => q.id === id); }
describe('serializeAllAnswers + decodeAnyPayload (#a=)', () => {
beforeEach(() => localStorage.clear());
it('serializes all 8 questionnaires, including the empty ones', () => {
const st = { answers: { 1: 3, 2: 4 }, pos: 2 };
localStorage.setItem(LS + 'bonheur_conjugal', JSON.stringify(st));
const all = serializeAllAnswers('moi');
expect(all.kind).toBe('all');
expect(all.questionnaires).toHaveLength(8);
const bonheur = all.questionnaires.find((b) => b.qid === 'bonheur_conjugal');
expect(bonheur.answered).toHaveLength(2);
expect(bonheur.pct).toBeGreaterThan(0);
const vide = all.questionnaires.find((b) => b.qid === 'folio_vie_en_couple');
expect(vide.answered).toHaveLength(0);
});
it('round-trips through base64url + decodeAnyPayload as kind=all', () => {
const all = serializeAllAnswers('moi');
const decoded = decodeAnyPayload(answersToBase64url(all));
expect(decoded.kind).toBe('all');
expect(decoded.payload.questionnaires).toHaveLength(8);
expect(decoded.payload.respondent).toBe('moi');
});
it('single-questionnaire payloads still decode as kind=one', () => {
const one = { qid: 'bonheur_conjugal', title: 'x', respondent: 'moi', answered: [{ i: '1', v: 3 }] };
const decoded = decodeAnyPayload(answersToBase64url(one));
expect(decoded.kind).toBe('one');
});
it('garbage returns null, never throws', () => {
expect(decodeAnyPayload('!!!not-base64!!!')).toBeNull();
expect(decodeAnyPayload(answersToBase64url({ random: true }))).toBeNull();
});
});
describe('discussionAdvice — la règle écrite du clinicien, porte telle quelle', () => {
it('bonheur_conjugal : discuter les 3-4, noter les 0-2', () => {
const a = discussionAdvice('bonheur_conjugal');
expect(a).toEqual({ discussFrom: 3, noteBelow: 3 });
});
it('les instruments sans règle écrite n’en inventent pas', () => {
expect(discussionAdvice('satisfaction_sexuelle')).toBeNull();
expect(discussionAdvice('communication')).toBeNull();
});
});
describe('compareAnswers — divergence par item, jamais un score de couple', () => {
it('labels both sides, flags divergence, treats missing as null', () => {
const q = Q('bonheur_conjugal');
const mine = { answers: { 1: 4, 2: 2 }, pos: 2 };
const theirBlock = { qid: 'bonheur_conjugal', answered: [{ i: '1', v: 2, l: 'Peu satisfait' }, { i: '3', v: 4, l: 'Satisfait' }] };
const rows = compareAnswers('bonheur_conjugal', mine, theirBlock, q);
expect(rows).toHaveLength(25);
expect(rows[0].mine).toBeTruthy();
expect(rows[0].theirs).toBeTruthy();
expect(rows[0].divergence).toBe(true);
expect(rows[1].theirs).toBeNull(); // l'autre n'a pas répondu à l'item 2
expect(rows[1].divergence).toBe(false);
});
});
/* Clarté TDAH — moteur : parité de scoring avec le POC vanilla (seuils,
présentation, bandes EF) et persistance sous contrat gelé. */
import {
scoreASRS, scoreDIVA, scoreExecutive, calculateResults,
readState, writeState, STORAGE_KEY, partAShadedCount, scope,
} from '@/components/tdah/engine';
import { TOTAL_QUESTIONS, PART_B_IDS, ALL_QUESTION_IDS } from '@/components/tdah/data';
describe('scoring parity with the vanilla POC', () => {
it('part A shades at thresholds 2/2/2/3/3/3 and screens positive at ≥4', () => {
// 4 réponses exactement au seuil : items 1-3 (2), item 4 (3), items 5-6 (0)
const r = { asrs_1: 2, asrs_2: 2, asrs_3: 2, asrs_4: 3, asrs_5: 0, asrs_6: 0 };
const s = scoreASRS(r);
expect(s.partAShaded).toBe(4);
expect(s.screeningPositive).toBe(true);
// cas limite : 3 cases grisées → dépistage non atteint
const r2 = { asrs_1: 2, asrs_2: 2, asrs_3: 2, asrs_4: 0, asrs_5: 0, asrs_6: 0 };
expect(scoreASRS(r2).screeningPositive).toBe(false);
});
it('edge: exactly 4 shaded with thresholds crossing (val = threshold - 1 does not shade)', () => {
const r = { asrs_1: 1, asrs_2: 2, asrs_3: 2, asrs_4: 2, asrs_5: 3, asrs_6: 0 };
// item 1 (seuil 2, val 1) ne grisera pas ; items 2,3 grisés (seuil 2), item 4 (seuil 3, val 2) non, item 5 (seuil 3, val 3) oui
expect(partAShadedCount(r)).toBe(3);
});
it('DSM: 5 of 9 in both → combined presentation (official keys, not legacy strings)', () => {
const r = {};
for (let i = 1; i <= 5; i++) r['diva_ia_' + i] = 1;
for (let i = 1; i <= 5; i++) r['diva_hi_' + i] = 1;
const d = scoreDIVA(r);
expect(d.meetsInattention).toBe(true);
expect(d.meetsHyperactivity).toBe(true);
expect(d.presentation).toBe('combined');
expect(['combined', 'inattentive', 'hyperactive', 'below']).toContain(d.presentation);
});
it('DSM: 4 of 9 stays below the clinical threshold', () => {
const r = {};
for (let i = 1; i <= 4; i++) r['diva_ia_' + i] = 1;
expect(scoreDIVA(r).meetsInattention).toBe(false);
});
it('EF: bands at 40 and 66 percent, severity keys are low/moderate/high', () => {
// 24 items 0..3 ; 40 % ⇒ total 28.8 → 29
const r29 = {}; let acc = 29;
ALL_QUESTION_IDS.filter((id) => id.startsWith('ef_')).forEach((id) => { const v = Math.min(3, acc); r29[id] = v; acc -= v; });
expect(scoreExecutive(r29).severity).toBe('moderate');
const rHigh = {}; ALL_QUESTION_IDS.filter((id) => id.startsWith('ef_')).forEach((id) => { rHigh[id] = 3; });
expect(scoreExecutive(rHigh).percentage).toBe(100);
expect(scoreExecutive(rHigh).severity).toBe('high');
const rLow = {}; ALL_QUESTION_IDS.filter((id) => id.startsWith('ef_')).forEach((id) => { rLow[id] = 0; });
expect(scoreExecutive(rLow).severity).toBe('low');
});
it('global positive indicators count the three tools', () => {
const r = {};
['asrs_1', 'asrs_2', 'asrs_3', 'asrs_4'].forEach((id) => { r[id] = 3; });
for (let i = 1; i <= 5; i++) r['diva_ia_' + i] = 1;
ALL_QUESTION_IDS.filter((id) => id.startsWith('ef_')).forEach((id) => { r[id] = 3; });
const g = calculateResults(r).global;
expect(g.positiveIndicators).toBe(3);
});
it('structure: 70 questions = 18 ASRS + 28 DSM-5 + 24 EF', () => {
expect(TOTAL_QUESTIONS).toBe(70);
expect(PART_B_IDS).toHaveLength(12);
});
it('scope: 70, or 58 with Part B skipped', () => {
expect(scope(false)).toBe(70);
expect(scope(true)).toBe(58);
});
});
describe('persistence contract (frozen: clarte:v1)', () => {
beforeEach(() => localStorage.clear());
it('round-trips {responses, currentSection, pos, partBSkipped, lastMilestone}', () => {
const st = { responses: { asrs_1: 2 }, currentSection: 'flow', pos: 3, partBSkipped: false, lastMilestone: null };
expect(writeState(st)).toBe(true);
expect(readState()).toEqual(st);
});
it('merges legacy vanilla records {responses, currentSection} without pos', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ responses: { asrs_1: 3 }, currentSection: 'asrs' }));
const s = readState();
expect(s.responses.asrs_1).toBe(3);
expect(s.currentSection).toBe('asrs');
expect(s.pos).toBeUndefined(); // the app layer defaults it
});
it('corrupt storage returns null, not a crash', () => {
localStorage.setItem(STORAGE_KEY, '{not json');
expect(readState()).toBeNull();
});
it('missing responses object returns null', () => {
localStorage.setItem(STORAGE_KEY, '{"currentSection":"home"}');
expect(readState()).toBeNull();
});
});
/* Clarté TDAH — flux : construction des nœuds (porte Partie B, ponts),
jalons de progression, completude du parcours 70 questions. */
import { questionnaireNodes } from '@/components/tdah/ClarteTdah';
describe('flow construction (spec §4, §7)', () => {
it('full flow: 2 intros ASRS-side + gate only under threshold + 70 questions + 2 bridges + recap', () => {
// seuil atteint : pas de porte
const r = { asrs_1: 3, asrs_2: 3, asrs_3: 3, asrs_4: 3, asrs_5: 0, asrs_6: 0 };
const nodes = questionnaireNodes(false, r);
const types = nodes.map((n) => n.type);
expect(types.filter((x) => x === 'question')).toHaveLength(70);
expect(types.filter((x) => x === 'gate')).toHaveLength(0);
expect(types[0]).toBe('intro');
expect(types[types.length - 1]).toBe('recap');
// ponts : A2 après ia_9, brown avant les EF
const ia9 = nodes.findIndex((n) => n.q && n.q.id === 'diva_ia_9');
expect(nodes[ia9 + 1].type).toBe('bridge');
expect(nodes[ia9 + 1].id).toBe('a2');
const imp5 = nodes.findIndex((n) => n.q && n.q.id === 'diva_imp_5');
expect(nodes[imp5 + 1].type).toBe('bridge');
expect(nodes[imp5 + 1].id).toBe('brown');
});
it('gate appears exactly once when Part A is under threshold and Part B untouched', () => {
const r = { asrs_1: 0, asrs_2: 0, asrs_3: 0, asrs_4: 0, asrs_5: 0, asrs_6: 0 };
const nodes = questionnaireNodes(false, r);
expect(nodes.filter((n) => n.type === 'gate')).toHaveLength(1);
const gateIdx = nodes.findIndex((n) => n.type === 'gate');
expect(nodes[gateIdx - 1].q.id).toBe('asrs_6');
expect(nodes[gateIdx + 1].q.id).toBe('asrs_7');
});
it('skipping Part B removes the gate and the 12 questions (scope 58)', () => {
const r = { asrs_1: 0, asrs_2: 0, asrs_3: 0, asrs_4: 0, asrs_5: 0, asrs_6: 0 };
const nodes = questionnaireNodes(true, r);
expect(nodes.filter((n) => n.type === 'gate')).toHaveLength(0);
expect(nodes.filter((n) => n.type === 'question')).toHaveLength(58);
expect(nodes.some((n) => n.q && n.q.id === 'asrs_7')).toBe(false);
});
it('Part B answered (any) removes the gate on re-flow', () => {
const r = { asrs_1: 0, asrs_2: 0, asrs_3: 0, asrs_4: 0, asrs_5: 0, asrs_6: 0, asrs_7: 2 };
const nodes = questionnaireNodes(false, r);
expect(nodes.filter((n) => n.type === 'gate')).toHaveLength(0);
});
});
/* Clarté TDAH — dictionnaires : complétude par rapport aux decks, zéro
tiret cadratin, typographie FR (espace insécable avant :), clés de questions
toutes résolues dans les deux langues. */
import { dict as fr } from '@/components/tdah/i18n/fr';
import { dict as en } from '@/components/tdah/i18n/en';
import { ALL_QUESTION_IDS, EXEC_CLUSTERS } from '@/components/tdah/data';
const REQUIRED = [
'meta.title', 'header.logo.h1', 'header.subtitle',
'home.h2', 'home.intro.p', 'home.card.asrs.p', 'home.card.dsm.p', 'home.card.ef.p',
'home.warning.h4', 'home.warning.li1', 'home.warning.li2', 'home.warning.li3',
'home.warning.li4', 'home.warning.li5', 'home.warning.li6',
'home.consent.label', 'home.start.btn', 'home.storage.line',
'progress.answered',
'intro.asrs.h', 'intro.asrs.p', 'intro.dsm.h', 'intro.dsm.p',
'intro.enfance.h', 'intro.enfance.p', 'intro.retentissement.h', 'intro.retentissement.p',
'intro.continue.btn', 'intro.skip.btn',
'bridge.a2.h', 'bridge.a2.p', 'bridge.brown.h', 'bridge.brown.p',
'note.label', 'note.asrs.adult.hyper', 'note.asrs.avoidance', 'note.ef.activation',
'note.ef.memory', 'note.ef.emotion', 'note.six.months', 'note.dsm.inattention', 'note.dsm.impulsivity',
'gate.title', 'gate.body', 'gate.answer.btn', 'gate.skip.btn',
'recap.title', 'recap.unanswered.chip', 'recap.complete.btn', 'recap.missing.count',
'recap.missing.go', 'recap.missing.skip', 'recap.review.fromresults',
'resume.count', 'resume.primary', 'resume.secondary',
'restart.confirm.body', 'restart.confirm.ok', 'restart.confirm.cancel',
'asrs.h2', 'asrs.source', 'asrs.info.p', 'asrs.partA.h3', 'asrs.partA.hint',
'asrs.partB.h3', 'asrs.partB.hint',
'results.warning.p', 'results.summary.h2', 'results.summary.text.high', 'results.summary.text.low',
'results.summary.text.empty', 'results.summary.bullet.asrs', 'results.summary.bullet.dsm', 'results.summary.bullet.ef',
'results.reco.high', 'results.reco.low',
'results.card1.h3', 'results.card1.status', 'results.card1.label.partA', 'results.card1.label.partB',
'results.card1.inattention', 'results.card1.hyperactivity',
'results.card2.h3', 'results.card3.h3', 'results.card3.status',
'results.presentation.below', 'results.presentation.combined', 'results.presentation.inattentive', 'results.presentation.hyperactive',
'results.download.btn', 'results.download.hint', 'results.restart.btn', 'alert.incomplete',
'asrs.interpretation.positive', 'asrs.interpretation.negative',
'understand.title', 'understand.asrs.p', 'understand.dsm.p', 'understand.ef.p',
'consult.title', 'consult.bring.p', 'consult.sentence.label', 'consult.sentence',
'consult.questions.h', 'consult.q1', 'consult.q2', 'consult.q3',
'glossary.link', 'glossary.title', 'glossary.tdah', 'glossary.asrs', 'glossary.dsm5',
'glossary.inattention', 'glossary.hyperactivite', 'glossary.impulsivite',
'glossary.fonctions.executives', 'glossary.memoire.travail', 'glossary.retentissement', 'glossary.depistage',
'milestone.asrs.done', 'milestone.half', 'milestone.dsm.done', 'milestone.all.done',
'pdf.title', 'pdf.warning.p', 'pdf.section1.h', 'pdf.section2.h', 'pdf.section3.h',
'pdf.annexe.h', 'pdf.unanswered', 'pdf.refs.h', 'pdf.pagefooter', 'pdf.passed.item',
'results.partB.skipped',
];
describe('dictionary completeness vs the decks', () => {
it('every required key exists in BOTH languages, non-empty', () => {
const missing = [];
REQUIRED.forEach((k) => {
if (!fr[k] && fr[k] !== '') missing.push('fr:' + k);
if (!en[k] && en[k] !== '') missing.push('en:' + k);
if (fr[k] === '' && k !== 'header.tagline') missing.push('fr-empty:' + k);
});
expect(missing).toEqual([]);
});
it('all 70 question keys resolve in both languages', () => {
const missing = [];
ALL_QUESTION_IDS.forEach((id) => {
const key = 'asrs.q' + id.split('_')[1] || null;
});
// questions ASRS
for (let i = 1; i <= 18; i++) {
if (!fr['asrs.q' + i]) missing.push('fr:asrs.q' + i);
if (!en['asrs.q' + i]) missing.push('en:asrs.q' + i);
}
// DSM
for (let i = 1; i <= 9; i++) {
if (!fr['diva.ia_' + i]) missing.push('fr:diva.ia_' + i);
if (!en['diva.ia_' + i]) missing.push('en:diva.ia_' + i);
if (!fr['diva.hi_' + i]) missing.push('fr:diva.hi_' + i);
if (!en['diva.hi_' + i]) missing.push('en:diva.hi_' + i);
}
for (let i = 1; i <= 5; i++) {
if (!fr['diva.child_' + i]) missing.push('fr:diva.child_' + i);
if (!fr['diva.imp_' + i]) missing.push('fr:diva.imp_' + i);
if (!en['diva.child_' + i]) missing.push('en:diva.child_' + i);
if (!en['diva.imp_' + i]) missing.push('en:diva.imp_' + i);
}
// EF
EXEC_CLUSTERS.forEach((c) => c.questions.forEach((q) => {
if (!fr[q.t]) missing.push('fr:' + q.t);
if (!en[q.t]) missing.push('en:' + q.t);
}));
expect(missing).toEqual([]);
});
it('no em dashes anywhere in shipped strings', () => {
const offenders = [];
Object.keys(fr).forEach((k) => { if (String(fr[k]).includes('—')) offenders.push('fr:' + k); });
Object.keys(en).forEach((k) => { if (String(en[k]).includes('—')) offenders.push('en:' + k); });
expect(offenders).toEqual([]);
});
it('French typography: NBSP before colons in sampled strings', () => {
const samples = ['recap.missing.count', 'results.summary.bullet.asrs', 'milestone.asrs.done'];
samples.forEach((k) => {
if (fr[k] && fr[k].includes(' :')) {
// si un deux-points existe, il doit être précédé d'une insécable
expect(fr[k]).not.toMatch(/[^ ] :/);
}
});
});
it('ASRS FR items are the official GAEA text (spot checks on the fixed items)', () => {
expect(fr['asrs.q1']).toContain('le gros du travail');
expect(fr['asrs.q1']).not.toContain('stimulantes');
expect(fr['asrs.q2']).toContain('établir les priorités');
expect(fr['asrs.q6']).toContain('comme une dynamo');
expect(fr['diva.ia_1']).toContain('fautes d’étourderie');
expect(en['asrs.q1']).toContain('final details');
expect(en['diva.ia_1']).toContain('careless mistakes');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment