Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions __tests__/questionnaires.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 ────────────────────────────────────────────────────────────────
Expand Down
126 changes: 104 additions & 22 deletions app/QuestionnaireModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,24 +126,50 @@ const YesNoInput = ({ value, onChange }) => (
</View>
);

/** 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 }) => (
<View style={styles.stepperCol}>
<TouchableOpacity style={[styles.stepBtn, { backgroundColor: C.primaryLight }]} onPress={() => adjust(field, 1)}>
<Pressable style={[styles.stepBtn, { backgroundColor: C.primaryLight }]}
onPress={() => adjust(field, 1)}
onLongPress={() => startLongPress(field, 1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="caret-up" size={20} color={C.primary} />
</TouchableOpacity>
</Pressable>
<Text style={[styles.stepValue, { color: C.primary }]}>{display}</Text>
<TouchableOpacity style={[styles.stepBtn, { backgroundColor: C.primaryLight }]} onPress={() => adjust(field, -1)}>
<Pressable style={[styles.stepBtn, { backgroundColor: C.primaryLight }]}
onPress={() => adjust(field, -1)}
onLongPress={() => startLongPress(field, -1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="caret-down" size={20} color={C.primary} />
</TouchableOpacity>
</Pressable>
</View>
);

return (
<View style={styles.timeRow}>
<Stepper field="hour" display={pad(hour)} />
Expand All @@ -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 (
<View style={styles.numberRow}>
<TouchableOpacity style={[styles.numBtn, { borderColor: C.primary }]} onPress={() => onChange(clamp(v - 1, min, max))}>
<Pressable style={[styles.numBtn, { borderColor: C.primary }]}
onPress={() => adjust(-1)}
onLongPress={() => startLongPress(-1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="remove" size={24} color={C.primary} />
</TouchableOpacity>
</Pressable>
<Text style={[styles.numValue, { color: C.primary }]}>{v}</Text>
<TouchableOpacity style={[styles.numBtn, { borderColor: C.primary }]} onPress={() => onChange(clamp(v + 1, min, max))}>
<Pressable style={[styles.numBtn, { borderColor: C.primary }]}
onPress={() => adjust(1)}
onLongPress={() => startLongPress(1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="add" size={24} color={C.primary} />
</TouchableOpacity>
</Pressable>
<Text style={[styles.numUnit, { color: C.primary }]}>{unit}</Text>
</View>
);
};

/** 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 (
<View style={styles.numberRow}>
<TouchableOpacity style={[styles.numBtn, { borderColor: C.primary }]} onPress={() => onChange(clamp(v - 1, min, max))}>
<Pressable style={[styles.numBtn, { borderColor: C.primary }]}
onPress={() => adjust(-1)}
onLongPress={() => startLongPress(-1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="remove" size={24} color={C.primary} />
</TouchableOpacity>
</Pressable>
<Text style={[styles.numValue, { color: C.primary }]}>{v}</Text>
<TouchableOpacity style={[styles.numBtn, { borderColor: C.primary }]} onPress={() => onChange(clamp(v + 1, min, max))}>
<Pressable style={[styles.numBtn, { borderColor: C.primary }]}
onPress={() => adjust(1)}
onLongPress={() => startLongPress(1)}
onPressOut={stopLongPress}
delayLongPress={300}>
<Ionicons name="add" size={24} color={C.primary} />
</TouchableOpacity>
</Pressable>
{!!unit && <Text style={[styles.numUnit, { color: C.primary }]}>{unit}</Text>}
</View>
);
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion app/QuestionnairesScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View key={q.id}>
<View style={styles.qRow}>
Expand All @@ -66,7 +82,7 @@ export default function QuestionnairesScreen() {
<View style={styles.qResultRow}>
<View style={[styles.qBadge, { backgroundColor: interpretation.color + '18', borderColor: interpretation.color }]}>
<Text style={[styles.qBadgeText, { color: interpretation.color, fontFamily: FONTS.body }]}>
{result.score} — {interpretation.label}
{scoreDisplay} — {interpretation.label}
</Text>
</View>
<Text style={[styles.qDate, { fontFamily: FONTS.bodyMedium }]}>
Expand Down
Loading
Loading