Commit 4a78f686 by PLN (Algolia)

Integrate instrument attributions and the conversation guide into the couple app

parent e0607f29
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { COUPLE_DATA } from './data'; import { COUPLE_DATA } from './data';
import { compareAnswers, discussionAdvice, displayTitle } from './engine'; import { compareAnswers, discussionAdvice, displayTitle } from './engine';
import { CONVERSATION_ETIQUETTE } from './attributions';
import { strings } from './strings'; import { strings } from './strings';
import styles from './couple.module.css'; import styles from './couple.module.css';
...@@ -37,14 +38,26 @@ export default function CompareView({ myQid, myRecord, theirBlock, onHome }) { ...@@ -37,14 +38,26 @@ export default function CompareView({ myQid, myRecord, theirBlock, onHome }) {
)} )}
</p> </p>
{advice && (
<div className={styles.adviceBand}> <div className={styles.adviceBand}>
<p>{strings.compare.adviceTitle}</p> <p><strong>{strings.compare.adviceTitle}</strong></p>
{advice && (
<p className={styles.muted}> <p className={styles.muted}>
{strings.compare.adviceRule(advice.discussFrom, advice.noteBelow)} {strings.compare.adviceRule(advice.discussFrom, advice.noteBelow)}
</p> </p>
</div>
)} )}
<p className={styles.muted}>{strings.compare.etiquetteTitle}</p>
<ul className={styles.etiquetteList}>
{CONVERSATION_ETIQUETTE.map(function (e) {
return <li key={e}>{e}</li>;
})}
</ul>
{advice && q.scoring && q.scoring.debrief && (
<details className={styles.attribDetails}>
<summary className={styles.attribSummary}>{strings.compare.fullAdvice}</summary>
<p className={styles.muted}>{q.scoring.debrief}</p>
</details>
)}
</div>
<table className={styles.compareTable}> <table className={styles.compareTable}>
<thead> <thead>
......
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
writeSession, writeSession,
} from './engine'; } from './engine';
import { exportCouplePdf } from './pdf-export'; import { exportCouplePdf } from './pdf-export';
import { ATTRIBUTIONS, RIGHTS_LINE } from './attributions';
import { strings } from './strings'; import { strings } from './strings';
import styles from './couple.module.css'; import styles from './couple.module.css';
...@@ -151,6 +152,19 @@ export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite, goCompa ...@@ -151,6 +152,19 @@ export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite, goCompa
<footer className={styles.pagefoot}> <footer className={styles.pagefoot}>
<p>{strings.home.footLine}</p> <p>{strings.home.footLine}</p>
<p className={styles.muted}>{RIGHTS_LINE}</p>
<details className={styles.attribDetails}>
<summary className={styles.attribSummary}>{strings.home.creditsTitle}</summary>
{COUPLE_DATA.questionnaires.map(function (q) {
var a = ATTRIBUTIONS[q.id];
if (!a) return null;
return (
<p key={q.id} className={styles.muted}>
<strong>{displayTitle(q.title)}</strong> : {a.source}
</p>
);
})}
</details>
<p className={styles.footlinks}> <p className={styles.footlinks}>
{confirmingDelete ? ( {confirmingDelete ? (
<> <>
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Button from '@/components/clarte/Button'; import Button from '@/components/clarte/Button';
import { answeredCount, displayTitle, firstUnanswered, readRecord, writeRecord } from './engine'; import { answeredCount, displayTitle, firstUnanswered, readRecord, writeRecord } from './engine';
import { ATTRIBUTIONS } from './attributions';
import { strings } from './strings'; import { strings } from './strings';
import styles from './couple.module.css'; import styles from './couple.module.css';
...@@ -68,6 +69,18 @@ export default function IntroView({ q, goHome, goQuiz, goRecap, trackWrite = fun ...@@ -68,6 +69,18 @@ export default function IntroView({ q, goHome, goQuiz, goRecap, trackWrite = fun
{ctaLabel} {ctaLabel}
</Button> </Button>
</div> </div>
<p className={styles.muted}>{(ATTRIBUTIONS[q.id] || {}).line}</p>
{(ATTRIBUTIONS[q.id] || {}).source && (
<details className={styles.attribDetails}>
<summary className={styles.attribSummary}>{strings.intro.originTitle}</summary>
<p className={styles.muted}>{ATTRIBUTIONS[q.id].source}</p>
{ATTRIBUTIONS[q.id].url && (
<p className={styles.muted}>
<a href={ATTRIBUTIONS[q.id].url} target="_blank" rel="noreferrer">source publiée par l’auteur</a>
</p>
)}
</details>
)}
{q.intro && <p>{q.intro}</p>} {q.intro && <p>{q.intro}</p>}
{q.instructions && <p>{q.instructions}</p>} {q.instructions && <p>{q.instructions}</p>}
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { COUPLE_DATA } from './data'; import { COUPLE_DATA } from './data';
import { displayTitle } from './engine'; import { displayTitle } from './engine';
import { RIGHTS_LINE } from './attributions';
import { strings } from './strings'; import { strings } from './strings';
import styles from './couple.module.css'; import styles from './couple.module.css';
...@@ -74,6 +75,7 @@ export default function SharedAllView({ payload }) { ...@@ -74,6 +75,7 @@ export default function SharedAllView({ payload }) {
})} })}
<p className={styles.muted}>{strings.shared.foot}</p> <p className={styles.muted}>{strings.shared.foot}</p>
<p className={styles.muted}>{RIGHTS_LINE}</p>
</div> </div>
); );
} }
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
import { COUPLE_DATA } from './data'; import { COUPLE_DATA } from './data';
import { allItems, displayTitle, optionLabel } from './engine'; import { allItems, displayTitle, optionLabel } from './engine';
import { strings } from './strings'; import { strings } from './strings';
import { RIGHTS_LINE } from './attributions';
import styles from './couple.module.css'; import styles from './couple.module.css';
export default function SharedView({ payload }) { export default function SharedView({ payload }) {
...@@ -72,6 +73,7 @@ export function SharedErrorView() { ...@@ -72,6 +73,7 @@ export function SharedErrorView() {
{strings.shared.backHome} {strings.shared.backHome}
</a> </a>
</p> </p>
<p className={styles.muted}>{RIGHTS_LINE}</p>
</div> </div>
); );
} }
/* Clarté couple — attributions des instruments. Contenu RÉDIGÉ PAR L'APP
(pas la donnée clinique) : la fiche de crédit de chaque questionnaire,
établie par l'enquête de provenance (Diag_ADHD/redesign/scale-provenance-
online.md, 2026-09-23, chaque source vérifiée ou marquée). data.js reste
intouché : on cite, on ne modifie jamais les textes d'origine. */
export const RIGHTS_LINE =
'Tous droits réservés à leurs auteurs respectifs. Tests présentés à titre informatif, sans valeur diagnostique.';
export const ATTRIBUTIONS = {
bonheur_conjugal: {
line: 'De Yvon Dallaire, psychologue (Québec).',
source:
'© Yvon Dallaire. Publié par l’auteur sur yvondallaire.com (test du bonheur conjugal, PDF libre). Les bandes d’interprétation sont les siennes, conservées telles quelles.',
url: 'https://yvondallaire.com/tests/test-de-bonheur-conjugal/',
},
satisfaction_sexuelle: {
line: 'De Yvon Dallaire, psychologue (Québec).',
source:
'© Yvon Dallaire. Publié par l’auteur sur yvondallaire.com (test de satisfaction sexuelle, PDF libre). Le support indique lui-même qu’il s’inspire des sexologues Djamel Benois et Olivier Walmacq.',
url: 'https://yvondallaire.com/tests/test-de-satisfaction-sexuelle/',
},
pensees_negatives: {
line: 'Instrument clinique : élaboré par Trinque (2000) sous la direction de Gilles Trudel (UQAM).',
source:
'Annexe de G. Trudel, « La baisse du désir sexuel », Elsevier Masson (ISBN 978-2-294-00999-5). Aucune copie publique n’existe : le texte vit dans ce support privé.',
},
communication: {
line: 'De Jean-Marie Boisvert et Josette Beaudry.',
source:
'Jean-Marie Boisvert et Josette Beaudry, « S’affirmer et communiquer », Presses de l’Université de Montréal (ISBN 978-2-7619-3525-8, grille de communication affirmative). Grille élargie à quatorze items pour ce support.',
},
inhibiteurs_excitateurs: {
line: 'Adaptation française de l’échelle d’Emily Nagoski, elle-même abrégée du SESII-W (Bancroft & Janssen).',
source:
'© 2015 Emily Nagoski, « Sexual Temperament Questionnaire », adapté et abrégé du SESII-W. Feuilles d’origine sur emilynagoski.com. Traduction libre : la version française de ce support n’est pas la traduction publiée.',
url: 'https://www.emilynagoski.com/come-as-you-are-worksheets',
},
desir_relationnel_comportemental: {
line: 'Instrument clinique : élaboré par Drouin (1999) sous la direction de Gilles Trudel (UQAM).',
source:
'Annexe de G. Trudel, « La baisse du désir sexuel », Elsevier Masson (ISBN 978-2-294-00999-5). Trace en ligne vérifiable : la page professorale de G. Trudel, UQAM.',
},
desir_individuel_cognitif: {
line: 'Instrument clinique : élaboré par Trinque (2000) sous la direction de Gilles Trudel (UQAM).',
source:
'Annexe de G. Trudel, « La baisse du désir sexuel », Elsevier Masson (ISBN 978-2-294-00999-5). Validé sur échantillon de femmes : le support d’origine le note, l’app le rappelle dans son introduction.',
},
folio_vie_en_couple: {
line: 'Feuille de pratique : auteur non identifié.',
source:
'Feuille de travail trouvée dans les supports de la praticienne, sans auteur identifiable. Sept questions à réponses écrites, sans cotation.',
},
};
/* L'étiquette de conversation, propre à l'app : le support de Dallaire pose
la règle la plus concrète (discuter les 3 et 4, noter les 0-2, chercher
« coupable » n'a jamais rien arrangé) ; les autres instruments n'écrivent
rien de tel, donc l'app offre une etiquette générique, clairement portée
par elle et non attribuée à l'instrument. */
export const CONVERSATION_ETIQUETTE = [
'Commencer par ce qui va : les réponses satisfaites se disent avant tout.',
'Une question à la fois, sans enchaîner vers les autres sujets.',
'Les réponses qui piquent se notent pour adapter son comportement, pas pour débattre de qui a raison.',
'Une réponse décrit un moment, comme une photo : ce n’est ni un verdict ni un contrat.',
];
...@@ -431,3 +431,12 @@ ...@@ -431,3 +431,12 @@
.rowDivergent { background: #fff; font-weight: 700; } .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 { 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); } .adviceBand p { margin: 0; font-size: var(--fs-small); }
/* Attributions et guide de conversation. */
.attribDetails { margin: var(--sp-1) 0; }
.attribSummary { color: var(--ink-faint); font-size: var(--fs-caption); cursor: pointer; }
.attribSummary:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.attribDetails p { font-size: var(--fs-caption); margin: var(--sp-1) 0; }
.attribDetails a { color: var(--teal-text); }
.etiquetteList { margin: var(--sp-1) 0; padding-left: 1.1rem; }
.etiquetteList li { font-size: var(--fs-small); margin: var(--sp-1) 0; }
...@@ -34,6 +34,9 @@ export const strings = { ...@@ -34,6 +34,9 @@ export const strings = {
'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.', '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)', pdfBtn: 'Exporter tout en PDF (documents remplis)',
pdfDone: 'PDF téléchargé', pdfDone: 'PDF téléchargé',
creditsTitle: 'Crédits et sources',
rightsLine:
'Tous droits réservés à leurs auteurs respectifs. Tests présentés à titre informatif, sans valeur diagnostique.',
}, },
intro: { intro: {
...@@ -46,6 +49,7 @@ export const strings = { ...@@ -46,6 +49,7 @@ export const strings = {
resume: (firstUnanswered1Based) => 'Reprendre à la question ' + firstUnanswered1Based, resume: (firstUnanswered1Based) => 'Reprendre à la question ' + firstUnanswered1Based,
seeRecap: 'Voir mes réponses', seeRecap: 'Voir mes réponses',
more: 'En savoir plus', more: 'En savoir plus',
originTitle: 'D’où vient ce questionnaire ?',
}, },
quiz: { quiz: {
...@@ -142,10 +146,12 @@ export const strings = { ...@@ -142,10 +146,12 @@ export const strings = {
colTheirs: 'L’autre', colTheirs: 'L’autre',
moi: 'cet appareil', moi: 'cet appareil',
autre: 'l’autre', autre: 'l’autre',
adviceTitle: 'Le conseil du support d’origine pour en parler :', adviceTitle: 'Comment en parler',
adviceRule: (from, below) => adviceRule: (from, below) =>
'Les réponses à ' + from + ' ou 4 sont faites pour être dites ; les réponses sous ' + below + 'Le conseil du support d’origine (Yvon Dallaire) : les réponses à ' + from + ' ou 4 sont faites pour être dites ; celles sous ' + below +
' se notent pour adapter son comportement, sans en faire un débat.', ' se notent pour adapter son comportement, sans en faire un débat.',
etiquetteTitle: 'Quelques habitudes qui aident (de l’app, pas du questionnaire) :',
fullAdvice: 'Le texte complet du support d’origine',
pcts: (mine, theirs) => pcts: (mine, theirs) =>
'Scores de l’instrument : ' + (mine || '—') + ' (moi) et ' + (theirs || '—') + (theirs ? '' : '') + '.', 'Scores de l’instrument : ' + (mine || '—') + ' (moi) et ' + (theirs || '—') + (theirs ? '' : '') + '.',
noScore: noScore:
......
...@@ -72,3 +72,35 @@ describe('compareAnswers — divergence par item, jamais un score de couple', () ...@@ -72,3 +72,35 @@ describe('compareAnswers — divergence par item, jamais un score de couple', ()
expect(rows[1].divergence).toBe(false); expect(rows[1].divergence).toBe(false);
}); });
}); });
/* ---------- attributions : chaque instrument crédité ---------- */
import { ATTRIBUTIONS, RIGHTS_LINE, CONVERSATION_ETIQUETTE } from '@/components/couple/attributions';
describe('attributions — provenance intégrale', () => {
it('every questionnaire has a credit entry with a line and a source', () => {
const missing = [];
COUPLE_DATA.questionnaires.forEach((q) => {
const a = ATTRIBUTIONS[q.id];
if (!a || !a.line || !a.source) missing.push(q.id);
});
expect(missing).toEqual([]);
});
it('credit entries match the research sheet (spot checks)', () => {
expect(ATTRIBUTIONS.bonheur_conjugal.source).toContain('yvondallaire.com');
expect(ATTRIBUTIONS.inhibiteurs_excitateurs.source).toContain('Nagoski');
expect(ATTRIBUTIONS.inhibiteurs_excitateurs.source).toContain('SESII-W');
expect(ATTRIBUTIONS.communication.source).toContain('Boisvert');
expect(ATTRIBUTIONS.pensees_negatives.source).toContain('Elsevier Masson');
expect(ATTRIBUTIONS.folio_vie_en_couple.line).toContain('non identifié');
});
it('rights line and etiquette carry no em dashes', () => {
expect(RIGHTS_LINE.includes('—')).toBe(false);
CONVERSATION_ETIQUETTE.forEach((e) => expect(e.includes('—')).toBe(false));
Object.values(ATTRIBUTIONS).forEach((a) => {
expect(a.line.includes('—')).toBe(false);
expect(a.source.includes('—')).toBe(false);
});
});
});
...@@ -73,9 +73,9 @@ describe('HomeView', () => { ...@@ -73,9 +73,9 @@ describe('HomeView', () => {
it('renders the 8 questionnaire cards with fresh meta (folio: écrits à compléter)', async () => { it('renders the 8 questionnaire cards with fresh meta (folio: écrits à compléter)', async () => {
setup(); setup();
await act(async () => {}); await act(async () => {});
const folioCard = screen.getByText('La vie en couple').closest('button'); const folioCard = screen.getAllByText('La vie en couple')[0].closest('button');
expect(folioCard.textContent).toContain('7 écrits à compléter'); expect(folioCard.textContent).toContain('7 écrits à compléter');
const bonheurCard = screen.getByText('Test du bonheur conjugal').closest('button'); const bonheurCard = screen.getAllByText('Test du bonheur conjugal')[0].closest('button');
expect(bonheurCard.textContent).toContain('25 questions'); expect(bonheurCard.textContent).toContain('25 questions');
}); });
...@@ -95,14 +95,14 @@ describe('HomeView', () => { ...@@ -95,14 +95,14 @@ describe('HomeView', () => {
it('routes fresh cards to intro, partial to firstUnanswered, complete to recap', async () => { it('routes fresh cards to intro, partial to firstUnanswered, complete to recap', async () => {
let mocks = setup(); let mocks = setup();
await act(async () => {}); await act(async () => {});
screen.getByText('Test du bonheur conjugal').closest('button').click(); screen.getAllByText('Test du bonheur conjugal')[0].closest('button').click();
expect(mocks.goIntro).toHaveBeenCalledWith('bonheur_conjugal'); expect(mocks.goIntro).toHaveBeenCalledWith('bonheur_conjugal');
cleanup(); cleanup();
seed('bonheur_conjugal', { answers: { 1: 4, 2: 2, 3: 0 }, pos: 0 }); seed('bonheur_conjugal', { answers: { 1: 4, 2: 2, 3: 0 }, pos: 0 });
mocks = setup(); mocks = setup();
await act(async () => {}); await act(async () => {});
const partialCard = screen.getByText('Test du bonheur conjugal').closest('button'); const partialCard = screen.getAllByText('Test du bonheur conjugal')[0].closest('button');
expect(partialCard.textContent).toContain('En cours · 3 sur 25'); expect(partialCard.textContent).toContain('En cours · 3 sur 25');
partialCard.click(); partialCard.click();
expect(mocks.goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 3); expect(mocks.goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 3);
...@@ -111,7 +111,7 @@ describe('HomeView', () => { ...@@ -111,7 +111,7 @@ describe('HomeView', () => {
seed('bonheur_conjugal', { answers: fullAnswers(25, 3), pos: 25 }); seed('bonheur_conjugal', { answers: fullAnswers(25, 3), pos: 25 });
mocks = setup(); mocks = setup();
await act(async () => {}); await act(async () => {});
const doneCard = screen.getByText('Test du bonheur conjugal').closest('button'); const doneCard = screen.getAllByText('Test du bonheur conjugal')[0].closest('button');
expect(doneCard.textContent).toContain('Terminé · voir mes réponses'); expect(doneCard.textContent).toContain('Terminé · voir mes réponses');
doneCard.click(); doneCard.click();
expect(mocks.goRecap).toHaveBeenCalledWith('bonheur_conjugal'); expect(mocks.goRecap).toHaveBeenCalledWith('bonheur_conjugal');
...@@ -522,6 +522,6 @@ describe('ClarteCouple — hash dispatch', () => { ...@@ -522,6 +522,6 @@ describe('ClarteCouple — hash dispatch', () => {
it('no hash boots to home with 8 cards', async () => { it('no hash boots to home with 8 cards', async () => {
render(<ClarteCouple />); render(<ClarteCouple />);
expect(await screen.findByText('Espace couple')).toBeInTheDocument(); expect(await screen.findByText('Espace couple')).toBeInTheDocument();
expect(screen.getByText('Test du bonheur conjugal')).toBeInTheDocument(); expect(screen.getAllByText('Test du bonheur conjugal')[0]).toBeInTheDocument();
}); });
}); });
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