diff --git a/__tests__/questionnaires.test.js b/__tests__/questionnaires.test.js index 56ee9cf..25d2342 100644 --- a/__tests__/questionnaires.test.js +++ b/__tests__/questionnaires.test.js @@ -329,20 +329,20 @@ describe('PSQI — interpret()', () => { describe('RU-SATED — score()', () => { it('returns 0 when all items are 0', () => { const a = {}; - for (let n = 1; n <= 7; n++) a[`rus${n}`] = 0; + for (let n = 1; n <= 6; n++) a[`rus${n}`] = 0; expect(RUSATED.score(a)).toBe(0); }); - it('returns 14 when all items are 2', () => { + it('returns 24 when all items are 4', () => { const a = {}; - for (let n = 1; n <= 7; n++) a[`rus${n}`] = 2; - expect(RUSATED.score(a)).toBe(14); + for (let n = 1; n <= 6; n++) a[`rus${n}`] = 4; + expect(RUSATED.score(a)).toBe(24); }); it('sums item values correctly', () => { - // 0+1+2+0+1+2+1 = 7 - const a = { rus1:0, rus2:1, rus3:2, rus4:0, rus5:1, rus6:2, rus7:1 }; - expect(RUSATED.score(a)).toBe(7); + // 0+1+2+3+4+2 = 12 + const a = { rus1:0, rus2:1, rus3:2, rus4:3, rus5:4, rus6:2 }; + expect(RUSATED.score(a)).toBe(12); }); it('treats missing items as 0', () => { @@ -351,12 +351,12 @@ describe('RU-SATED — score()', () => { }); describe('RU-SATED — interpret()', () => { - it('Poor sleep health at score 0', () => expect(RUSATED.interpret(0).label).toBe('Poor sleep health')); - it('Poor sleep health at score 4', () => expect(RUSATED.interpret(4).label).toBe('Poor sleep health')); - it('Moderate sleep health at score 5', () => expect(RUSATED.interpret(5).label).toBe('Moderate sleep health')); - it('Moderate sleep health at score 9', () => expect(RUSATED.interpret(9).label).toBe('Moderate sleep health')); - it('Good sleep health at score 10', () => expect(RUSATED.interpret(10).label).toBe('Good sleep health')); - it('Good sleep health at score 14', () => expect(RUSATED.interpret(14).label).toBe('Good sleep health')); + it('Poor sleep health at score 0', () => expect(RUSATED.interpret(0).label).toBe('Poor sleep health')); + it('Poor sleep health at score 8', () => expect(RUSATED.interpret(8).label).toBe('Poor sleep health')); + it('Moderate sleep health at score 9', () => expect(RUSATED.interpret(9).label).toBe('Moderate sleep health')); + it('Moderate sleep health at score 16', () => expect(RUSATED.interpret(16).label).toBe('Moderate sleep health')); + it('Good sleep health at score 17', () => expect(RUSATED.interpret(17).label).toBe('Good sleep health')); + it('Good sleep health at score 24', () => expect(RUSATED.interpret(24).label).toBe('Good sleep health')); }); // ─── STOP-BANG ──────────────────────────────────────────────────────────────── diff --git a/app/QuestionnaireModal.jsx b/app/QuestionnaireModal.jsx index d927195..37ab463 100644 --- a/app/QuestionnaireModal.jsx +++ b/app/QuestionnaireModal.jsx @@ -19,10 +19,10 @@ * * Theme: purple/violet to distinguish from morning (amber) and evening (blue). */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Modal, View, Text, TouchableOpacity, ScrollView, - StyleSheet, Platform, TextInput, + StyleSheet, Platform, TextInput, Pressable, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -126,24 +126,50 @@ const YesNoInput = ({ value, onChange }) => ( ); -/** HH:MM time stepper */ +/** HH:MM time stepper with long-press repeat */ const TimeInput = ({ value, onChange }) => { const { hour, minute } = value ?? { hour: 23, minute: 0 }; - const adjust = (field, delta) => { - if (field === 'hour') onChange({ hour: (hour + delta + 24) % 24, minute }); - if (field === 'minute') onChange({ hour, minute: (minute + delta + 60) % 60 }); - }; + const intervalRef = useRef(null); + const valueRef = useRef(value ?? { hour: 23, minute: 0 }); + useEffect(() => { valueRef.current = value ?? { hour: 23, minute: 0 }; }, [value]); + + const adjust = useCallback((field, delta) => { + const p = valueRef.current; + if (field === 'hour') onChange({ ...p, hour: (p.hour + delta + 24) % 24 }); + if (field === 'minute') onChange({ ...p, minute: (p.minute + delta + 60) % 60 }); + }, [onChange]); + + const startLongPress = useCallback((field, delta) => { + adjust(field, delta); + intervalRef.current = setInterval(() => adjust(field, delta), 150); + }, [adjust]); + + const stopLongPress = useCallback(() => { + if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + }, []); + + useEffect(() => () => stopLongPress(), []); + const Stepper = ({ field, display }) => ( - adjust(field, 1)}> + adjust(field, 1)} + onLongPress={() => startLongPress(field, 1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + {display} - adjust(field, -1)}> + adjust(field, -1)} + onLongPress={() => startLongPress(field, -1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + ); + return ( @@ -153,35 +179,91 @@ const TimeInput = ({ value, onChange }) => { ); }; -/** Integer minutes stepper */ +/** Integer minutes stepper with long-press repeat */ const DurationMinInput = ({ value, onChange, min = 0, max = 180, unit = 'min' }) => { const v = value ?? 0; + const intervalRef = useRef(null); + const valueRef = useRef(v); + useEffect(() => { valueRef.current = value ?? 0; }, [value]); + + const adjust = useCallback((delta) => { + const next = Math.min(Math.max(valueRef.current + delta, min), max); + onChange(next); + }, [onChange, min, max]); + + const startLongPress = useCallback((delta) => { + adjust(delta); + intervalRef.current = setInterval(() => adjust(delta), 150); + }, [adjust]); + + const stopLongPress = useCallback(() => { + if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + }, []); + + useEffect(() => () => stopLongPress(), []); + return ( - onChange(clamp(v - 1, min, max))}> + adjust(-1)} + onLongPress={() => startLongPress(-1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + {v} - onChange(clamp(v + 1, min, max))}> + adjust(1)} + onLongPress={() => startLongPress(1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + {unit} ); }; -/** Generic integer stepper */ +/** Generic integer stepper with long-press repeat */ const NumberInput = ({ value, onChange, min = 0, max = 99, unit = '' }) => { const v = value ?? min; + const intervalRef = useRef(null); + const valueRef = useRef(v); + useEffect(() => { valueRef.current = value ?? min; }, [value]); + + const adjust = useCallback((delta) => { + const next = Math.min(Math.max(valueRef.current + delta, min), max); + onChange(next); + }, [onChange, min, max]); + + const startLongPress = useCallback((delta) => { + adjust(delta); + intervalRef.current = setInterval(() => adjust(delta), 150); + }, [adjust]); + + const stopLongPress = useCallback(() => { + if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + }, []); + + useEffect(() => () => stopLongPress(), []); + return ( - onChange(clamp(v - 1, min, max))}> + adjust(-1)} + onLongPress={() => startLongPress(-1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + {v} - onChange(clamp(v + 1, min, max))}> + adjust(1)} + onLongPress={() => startLongPress(1)} + onPressOut={stopLongPress} + delayLongPress={300}> - + {!!unit && {unit}} ); @@ -233,7 +315,7 @@ const ResultScreen = ({ questionnaire, score, resultsUnlocked, onClose }) => { } } else { scoreDisplay = String(score); - const maxScore = questionnaire.items.reduce((mx, item) => { + const maxScore = questionnaire.maxScore ?? questionnaire.items.reduce((mx, item) => { if (!item.options) return mx; const itemMax = Math.max(...item.options.map((o) => o.value)); return mx + itemMax; diff --git a/app/QuestionnairesScreen.jsx b/app/QuestionnairesScreen.jsx index 3828360..9f66a1f 100644 --- a/app/QuestionnairesScreen.jsx +++ b/app/QuestionnairesScreen.jsx @@ -50,6 +50,22 @@ export default function QuestionnairesScreen() { {QUESTIONNAIRES.map((q, i, arr) => { const result = qResults[q.id]; const interpretation = (result && resultsUnlocked) ? q.interpret(result.score) : null; + + // Format score for display — handle object scores (e.g. MCTQ) + let scoreDisplay = ''; + if (result && resultsUnlocked) { + if (typeof result.score === 'object' && result.score !== null) { + if (result.score.msf_sc !== undefined) { + const h = Math.floor(result.score.msf_sc); + const m = Math.round((result.score.msf_sc % 1) * 60); + scoreDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} MSFsc`; + } else { + scoreDisplay = JSON.stringify(result.score); + } + } else { + scoreDisplay = String(result.score); + } + } return ( @@ -66,7 +82,7 @@ export default function QuestionnairesScreen() { - {result.score} — {interpretation.label} + {scoreDisplay} — {interpretation.label} diff --git a/data/questionnaires.js b/data/questionnaires.js index 2664e87..230303c 100644 --- a/data/questionnaires.js +++ b/data/questionnaires.js @@ -75,7 +75,6 @@ const FREQUENCY_4 = [ export const ESS = { id: 'ess', - beta: true, title: 'Epworth Sleepiness Scale', shortTitle: 'ESS', credit: 'Johns, M. W. (1991). Sleep, 14(6), 540–545. © Murray W. Johns. Permission required for commercial use.', @@ -110,7 +109,6 @@ export const ESS = { export const ISI = { id: 'isi', - beta: true, title: 'Insomnia Severity Index', shortTitle: 'ISI', credit: 'Morin, C. M., et al. (2011). Sleep, 34(5), 601–608. © Charles M. Morin. Available for non-commercial research use.', @@ -163,7 +161,6 @@ export const ISI = { export const DBAS16 = { id: 'dbas16', - beta: true, title: 'Dysfunctional Beliefs and Attitudes about Sleep', shortTitle: 'DBAS-16', credit: 'Morin, C. M., Vallières, A., & Ivers, H. (2007). Sleep, 30(11), 1547–1554. © Charles M. Morin. Available via MAPI Research Trust for research use.', @@ -245,7 +242,6 @@ export const KSS = { export const MEQ = { id: 'meq', - beta: true, title: 'Morningness–Eveningness Questionnaire', shortTitle: 'MEQ', credit: 'Horne, J. A., & Östberg, O. (1976). International Journal of Chronobiology, 4(2), 97–110. In the public domain.', @@ -349,7 +345,7 @@ export const MEQ = { export const PSQI = { id: 'psqi', - beta: true, + maxScore: 21, title: 'Pittsburgh Sleep Quality Index', shortTitle: 'PSQI', credit: 'Buysse, D. J., et al. (1989). Psychiatry Research, 28(2), 193–213. © University of Pittsburgh. Permission required; contact the authors for research use.', @@ -421,13 +417,13 @@ export const PSQI = { // C3 — Sleep duration (Q4, hours) const sd = answers['psqi4'] ?? 7; - const c3 = sd > 7 ? 0 : sd >= 6 ? 1 : sd >= 5 ? 2 : 3; + const c3 = sd >= 7 ? 0 : sd >= 6 ? 1 : sd >= 5 ? 2 : 3; // C4 — Habitual sleep efficiency (Q1, Q3, Q4) const bt = answers['psqi1'] ? answers['psqi1'].hour * 60 + answers['psqi1'].minute : 23 * 60; const wt = answers['psqi3'] ? answers['psqi3'].hour * 60 + answers['psqi3'].minute : 7 * 60; let tib = wt - bt; if (tib <= 0) tib += 1440; - const hse = tib > 0 ? ((sd * 60) / tib) * 100 : 0; + const hse = tib > 0 ? (sd / (tib / 60)) * 100 : 0; const c4 = hse >= 85 ? 0 : hse >= 75 ? 1 : hse >= 65 ? 2 : 3; // C5 — Sleep disturbances (Q5b–Q5i) @@ -453,30 +449,40 @@ export const PSQI = { // ─── RU-SATED ───────────────────────────────────────────────────────────────── +const RUSATED_OPTIONS = [ + { value: 0, label: 'Never' }, + { value: 1, label: 'Rarely' }, + { value: 2, label: 'Sometimes' }, + { value: 3, label: 'Often' }, + { value: 4, label: 'Always' }, +]; + export const RUSATED = { id: 'rusated', - beta: true, - title: 'RU-SATED Sleep Health Scale', + maxScore: 24, + title: 'Ru-SATED Sleep Health Scale', shortTitle: 'RU-SATED', - credit: 'Buysse, D. J. (2014). Sleep, 37(1), 9–17. © University of Pittsburgh. Permission required; contact the authors for research use.', + credit: 'Buysse, D. J. (2014). Sleep, 37(1), 9–17. © 2016 University of Pittsburgh. May not be used without permission.', instructions: - 'For each of the following questions, please select the response that best describes your sleep over the past month.', + 'The following statements refer to your sleep during the past one month. Please indicate the best ' + + 'response for each statement. “Night” refers to the time you get your longest sleep of the day, which ' + + 'may not be when it is dark out. “Day” refers to the time of day when you are usually awake. ' + + '“Sleep” refers to the longest period of sleep you have in a 24-hour day.', reference: 'Buysse, D. J. (2014). Sleep, 37(1), 9–17.', items: [ - { id: 'rus1', number: 1, text: 'Regularity — How often do you wake up at the same time each day (including weekends and days off)?', type: 'frequency_3', options: FREQUENCY_3 }, - { id: 'rus2', number: 2, text: 'Waking satisfaction — How often do you have difficulty waking up in the morning?', type: 'frequency_3', options: FREQUENCY_3_REV }, - { id: 'rus3', number: 3, text: 'Satisfaction — How often are you satisfied with your sleep?', type: 'frequency_3', options: FREQUENCY_3 }, - { id: 'rus4', number: 4, text: 'Alertness — How often do you have trouble staying awake during the day?', type: 'frequency_3', options: FREQUENCY_3_REV }, - { id: 'rus5', number: 5, text: 'Timing — How often do you fall asleep between 10 PM and midnight?', type: 'frequency_3', options: FREQUENCY_3 }, - { id: 'rus6', number: 6, text: 'Efficiency — How often do you sleep for at least 85% of the time that you are in bed?', type: 'frequency_3', options: FREQUENCY_3 }, - { id: 'rus7', number: 7, text: 'Duration — How often do you sleep 7 hours or more each night?', type: 'frequency_3', options: FREQUENCY_3 }, + { id: 'rus1', number: 1, text: 'I go to sleep and wake up at about the same time every day.', type: 'single_choice', options: RUSATED_OPTIONS }, + { id: 'rus2', number: 2, text: 'I sleep 7–9 hours per night.', type: 'single_choice', options: RUSATED_OPTIONS }, + { id: 'rus3', number: 3, text: 'The middle of my sleep period is between 2:00 AM and 4:00 AM.', type: 'single_choice', options: RUSATED_OPTIONS }, + { id: 'rus4', number: 4, text: 'I am awake for less than 30 minutes between the time I go to bed and the time I get out of bed.', type: 'single_choice', options: RUSATED_OPTIONS }, + { id: 'rus5', number: 5, text: 'I stay awake all day without dozing.', type: 'single_choice', options: RUSATED_OPTIONS }, + { id: 'rus6', number: 6, text: 'I am satisfied with my sleep.', type: 'single_choice', options: RUSATED_OPTIONS }, ], score: (answers) => - [1,2,3,4,5,6,7].reduce((s, n) => s + (answers[`rus${n}`] ?? 0), 0), + [1,2,3,4,5,6].reduce((s, n) => s + (answers[`rus${n}`] ?? 0), 0), interpret: (score) => { - if (score >= 10) return { label: 'Good sleep health', color: '#2E7D32', description: 'Your score suggests good multidimensional sleep health.' }; - if (score >= 5) return { label: 'Moderate sleep health', color: '#F59E0B', description: 'Your score suggests moderate sleep health. There may be room for improvement.' }; + if (score >= 17) return { label: 'Good sleep health', color: '#2E7D32', description: 'Your score suggests good multidimensional sleep health.' }; + if (score >= 9) return { label: 'Moderate sleep health', color: '#F59E0B', description: 'Your score suggests moderate sleep health. There may be room for improvement.' }; return { label: 'Poor sleep health', color: '#DC2626', description: 'Your score suggests poor sleep health across multiple dimensions. Consider speaking with a clinician.' }; }, }; @@ -485,7 +491,6 @@ export const RUSATED = { export const STOPBANG = { id: 'stopbang', - beta: true, title: 'STOP-BANG Questionnaire', shortTitle: 'STOP-BANG', credit: 'Chung, F., et al. (2016). Chest, 149(3), 631–638. © University Health Network, Toronto. Freely available for clinical and non-commercial research use (stopbang.ca).', @@ -518,7 +523,6 @@ export const STOPBANG = { export const MCTQ = { id: 'mctq', - beta: true, title: 'Munich Chronotype Questionnaire', shortTitle: 'MCTQ', credit: 'Roenneberg, T., et al. (2003). Journal of Biological Rhythms, 18(1), 80–90. © Till Roenneberg, LMU Munich. Free for non-commercial research; contact thewep.org for permission.', @@ -543,12 +547,8 @@ export const MCTQ = { { id: 'mctq_wt_f', number: 'B4', text: 'Free days — What time do you usually wake up?', type: 'time', defaultValue: { hour: 8, minute: 30 } }, ], score: (answers) => { - // All times converted to decimal hours (post-midnight times expressed as >24h) - const toH = (t) => { - if (!t) return null; - let h = t.hour + t.minute / 60; - return h; // caller handles wrap - }; + // All times converted to decimal hours + const toH = (t) => t ? t.hour + t.minute / 60 : null; const wd = answers['mctq_wd'] ?? 5; const fd = 7 - wd; @@ -558,10 +558,9 @@ export const MCTQ = { const sl_w = (answers['mctq_sl_w'] ?? 15) / 60; let so_w = bt_w + sl_w; let wt_w = toH(answers['mctq_wt_w'] ?? { hour: 7, minute: 0 }); - if (wt_w < so_w - 12) wt_w += 24; // handle midnight wrap - if (wt_w <= so_w) wt_w += 24; + if (wt_w <= so_w) wt_w += 24; // handle midnight wrap const sd_w = wt_w - so_w; - const msw = so_w + sd_w / 2; + const msw = (so_w + sd_w / 2) % 24; // Free day sleep onset & duration let bt_f = toH(answers['mctq_bt_f'] ?? { hour: 0, minute: 0 }); @@ -572,21 +571,28 @@ export const MCTQ = { if (wt_f < 12) wt_f += 24; if (wt_f <= so_f) wt_f += 24; const sd_f = wt_f - so_f; - const msf = so_f + sd_f / 2; - - // Sleep debt correction - const sd_week = wd > 0 || fd > 0 ? (sd_w * wd + sd_f * fd) / 7 : sd_f; - const deficit = sd_week - sd_w; - const msf_sc = deficit > 0 ? msf - deficit / 2 : msf; - - // Social jetlag - const sjl = Math.abs(msf - msw); + const msf = (so_f + sd_f / 2) % 24; + + // MSFsc — sleep-corrected mid-sleep on free days + // Per Roenneberg et al.: if SD_F <= SD_W, no correction needed. + // Otherwise: MSFsc = MSF - (SD_F - SD_week) / 2 + const sd_week = (sd_w * wd + sd_f * fd) / 7; + let msf_sc; + if (sd_f <= sd_w) { + msf_sc = msf; + } else { + msf_sc = msf - (sd_f - sd_week) / 2; + } + // Normalise to 0–24 clock range + msf_sc = ((msf_sc % 24) + 24) % 24; - // Normalise MSFsc to 0–24 clock range - let chronoH = msf_sc % 24; - if (chronoH < 0) chronoH += 24; + // SJL — absolute social jetlag using circular (shorter arc) difference + // |MSF - MSW| on a 24h clock, taking the shorter of the two arcs + let diff = Math.abs(msf - msw); + if (diff > 12) diff = 24 - diff; + const sjl = diff; - return { msf_sc: Math.round(chronoH * 100) / 100, sjl: Math.round(sjl * 100) / 100 }; + return { msf_sc: Math.round(msf_sc * 100) / 100, sjl: Math.round(sjl * 100) / 100 }; }, interpret: (score) => { // score is { msf_sc, sjl } diff --git a/i18n/en.js b/i18n/en.js index 3f702a0..833c4e7 100644 --- a/i18n/en.js +++ b/i18n/en.js @@ -243,7 +243,7 @@ export default { notYetCompleted: 'Not yet completed', resultsAfter14: 'Results available after 14 days', completed: 'Completed', - betaFootnote: 'These questionnaires are experimental. Scoring algorithms and interpretations are provided for informational purposes only and may not be fully accurate. Always verify results against validated published sources before use in research or clinical practice. Full licensing details are available under Settings → Questionnaire Credits.', + betaFootnote: 'Scoring algorithms and interpretations are provided for informational purposes only and may not be fully accurate. Always verify results against validated published sources before use in research or clinical practice. Full licensing details are available under Settings → Questionnaire Credits.', redoTitle: 'Replace existing result?', redoBody: 'This will permanently overwrite your previous {{title}} result. Are you sure?', redoCancel: 'Cancel', diff --git a/i18n/pt-BR.js b/i18n/pt-BR.js index 91ea268..0f1e724 100644 --- a/i18n/pt-BR.js +++ b/i18n/pt-BR.js @@ -303,7 +303,7 @@ export default { notYetCompleted: 'Ainda não concluído', resultsAfter14: 'Resultados disponíveis após 14 dias', completed: 'Concluído em', - betaFootnote: 'Estes questionários são experimentais. Os algoritmos de pontuação e interpretações são fornecidos apenas para fins informativos e podem não ser totalmente precisos. Sempre verifique os resultados em relação às fontes publicadas validadas antes de usar em pesquisa ou prática clínica. Detalhes completos de licenciamento estão disponíveis em Configurações → Créditos dos Questionários.', + betaFootnote: 'Os algoritmos de pontuação e interpretações são fornecidos apenas para fins informativos e podem não ser totalmente precisos. Sempre verifique os resultados em relação às fontes publicadas validadas antes de usar em pesquisa ou prática clínica. Detalhes completos de licenciamento estão disponíveis em Configurações → Créditos dos Questionários.', redoTitle: 'Substituir resultado existente?', redoBody: 'Isso vai substituir permanentemente o seu resultado anterior do {{title}}. Tem certeza?', redoCancel: 'Cancelar',