diff --git a/common.php b/common.php index b019ae8..640fc52 100644 --- a/common.php +++ b/common.php @@ -2321,8 +2321,8 @@ function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $rep $now = time(); $pdo->prepare( 'INSERT INTO scoring_profile (profileID, name, description, isActive, - greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) - VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)' + greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt) + VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :pcb, :ca, :ua)' )->execute([ ':id' => $profileID, ':name' => $name, @@ -2333,6 +2333,7 @@ function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $rep ':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)), ':ymax' => (int)($profile['yellowMax'] ?? 36), ':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)), + ':pcb' => qdb_encode_process_complete_bands($profile['processCompleteBands'] ?? []), ':ca' => (int)($profile['createdAt'] ?? $now), ':ua' => (int)($profile['updatedAt'] ?? $now), ]); diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 27af134..73ce354 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -105,11 +105,16 @@ "passwords_dont_match", "please_client_code", "please_username_password", + "process_complete_body", + "process_complete_ok", + "process_complete_set_category", + "process_complete_title", "points", "previous", "question", "questions_filled", "refresh", + "review_required_before_continue", "review_scores", "review_scores_agree", "review_scores_calculated_category", @@ -252,11 +257,16 @@ "passwords_dont_match": "Passwörter stimmen nicht überein", "please_client_code": "Bitte Klienten Code eingeben", "please_username_password": "Bitte Benutzername und Passwort eingeben.", + "process_complete_body": "Dieser Klient hat den Prozess abgeschlossen — alle verfügbaren Fragebögen sind erledigt oder eine als abgeschlossen markierte Kategorie wurde gesetzt. Sie können die Kategorie ändern, um weitere Fragebögen freizuschalten.", + "process_complete_ok": "OK", + "process_complete_set_category": "Andere Kategorie setzen", + "process_complete_title": "Prozess abgeschlossen", "points": "Punkte", "previous": "Zurück", "question": "Frage", "questions_filled": "Antworten", "refresh": "Aktualisieren", + "review_required_before_continue": "Bitte schließen Sie zuerst die Punkteprüfung ab, bevor Sie weitere Fragebögen ausfüllen.", "review_scores": "Punkte prüfen", "review_scores_agree": "Berechnete Kategorie bestätigen", "review_scores_calculated_category": "Berechnete Kategorie", diff --git a/db_init.php b/db_init.php index 9b0d95e..3aa0649 100644 --- a/db_init.php +++ b/db_init.php @@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) { define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); } define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 12); +define('QDB_VERSION', 13); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -227,6 +227,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { yellowMin INTEGER NOT NULL DEFAULT 13, yellowMax INTEGER NOT NULL DEFAULT 36, redMin INTEGER NOT NULL DEFAULT 37, + processCompleteBands TEXT NOT NULL DEFAULT '[]', createdAt INTEGER NOT NULL DEFAULT 0, updatedAt INTEGER NOT NULL DEFAULT 0 ) @@ -339,6 +340,14 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $changed = true; } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) { + if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) { + $pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'"); + $changed = true; + } + } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { $pdo->exec( diff --git a/handlers/scoring_profiles.php b/handlers/scoring_profiles.php index f15c37d..8daca68 100644 --- a/handlers/scoring_profiles.php +++ b/handlers/scoring_profiles.php @@ -43,14 +43,15 @@ case 'POST': if ($members === []) { json_error('INVALID_FIELD', 'At least one questionnaire is required', 400); } + $processCompleteBands = qdb_parse_process_complete_bands($body['processCompleteBands'] ?? []); try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $profileID = bin2hex(random_bytes(16)); $now = time(); $pdo->prepare( 'INSERT INTO scoring_profile (profileID, name, description, isActive, - greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) - VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)' + greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt) + VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :pcb, :ca, :ua)' )->execute([ ':id' => $profileID, ':name' => $name, @@ -61,6 +62,7 @@ case 'POST': ':ymin' => $bands['yellowMin'], ':ymax' => $bands['yellowMax'], ':rmin' => $bands['redMin'], + ':pcb' => qdb_encode_process_complete_bands($processCompleteBands), ':ca' => $now, ':ua' => $now, ]); @@ -101,10 +103,13 @@ case 'PUT': qdb_discard($tmpDb, $lockFp); json_error('INVALID_FIELD', 'At least one questionnaire is required', 400); } + $processCompleteBands = array_key_exists('processCompleteBands', $body) + ? qdb_parse_process_complete_bands($body['processCompleteBands']) + : qdb_row_process_complete_bands($existing); $pdo->prepare( 'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act, greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin, - updatedAt = :ua WHERE profileID = :id' + processCompleteBands = :pcb, updatedAt = :ua WHERE profileID = :id' )->execute([ ':name' => $name, ':desc' => trim($body['description'] ?? $existing['description']), @@ -114,6 +119,7 @@ case 'PUT': ':ymin' => $bands['yellowMin'], ':ymax' => $bands['yellowMax'], ':rmin' => $bands['redMin'], + ':pcb' => qdb_encode_process_complete_bands($processCompleteBands), ':ua' => time(), ':id' => $profileID, ]); diff --git a/lib/scoring.php b/lib/scoring.php index 9923f12..0cfd769 100644 --- a/lib/scoring.php +++ b/lib/scoring.php @@ -473,7 +473,7 @@ function qdb_list_scoring_profiles(PDO $pdo): array { $stmt = $pdo->query( 'SELECT profileID, name, description, isActive, greenMin, greenMax, yellowMin, yellowMax, redMin, - createdAt, updatedAt + processCompleteBands, createdAt, updatedAt FROM scoring_profile ORDER BY name' ); $profiles = []; @@ -481,6 +481,7 @@ function qdb_list_scoring_profiles(PDO $pdo): array { $row['isActive'] = (int)$row['isActive']; $bands = qdb_normalize_scoring_bands($row); $row = array_merge($row, $bands); + $row['processCompleteBands'] = qdb_row_process_complete_bands($row); $row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']); $profiles[] = $row; } @@ -491,7 +492,7 @@ function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array { $stmt = $pdo->prepare( 'SELECT profileID, name, description, isActive, greenMin, greenMax, yellowMin, yellowMax, redMin, - createdAt, updatedAt + processCompleteBands, createdAt, updatedAt FROM scoring_profile WHERE profileID = :pid' ); $stmt->execute([':pid' => $profileID]); @@ -501,6 +502,7 @@ function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array { } $row['isActive'] = (int)$row['isActive']; $row = array_merge($row, qdb_normalize_scoring_bands($row)); + $row['processCompleteBands'] = qdb_row_process_complete_bands($row); $row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID); return $row; } @@ -598,12 +600,13 @@ function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string { $now = time(); $pdo->prepare( 'INSERT INTO scoring_profile (profileID, name, description, isActive, - greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt) - VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)' + greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt) + VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :pcb, :ca, :ua)' )->execute([ ':id' => $profileID, ':name' => 'RHS Index', ':desc' => 'Legacy RHS thresholds (green 0–12, yellow 13–36, red 37+)', + ':pcb' => '[]', ':ca' => $now, ':ua' => $now, ]); @@ -618,6 +621,56 @@ function qdb_is_valid_scoring_band(?string $band): bool { return in_array($band, ['green', 'yellow', 'red'], true); } +/** + * @param mixed $raw + * @return list + */ +function qdb_parse_process_complete_bands($raw): array { + if (is_string($raw)) { + $decoded = json_decode($raw, true); + $raw = is_array($decoded) ? $decoded : []; + } + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $band) { + $b = strtolower(trim((string)$band)); + if (qdb_is_valid_scoring_band($b)) { + $out[] = $b; + } + } + return array_values(array_unique($out)); +} + +/** + * @return list + */ +function qdb_decode_process_complete_bands(?string $json): array { + if ($json === null || trim($json) === '') { + return []; + } + return qdb_parse_process_complete_bands(json_decode($json, true)); +} + +/** + * @param list $bands + */ +function qdb_encode_process_complete_bands(array $bands): string { + return json_encode(qdb_parse_process_complete_bands($bands), JSON_UNESCAPED_UNICODE); +} + +/** + * @param array $row + * @return list + */ +function qdb_row_process_complete_bands(array $row): array { + if (array_key_exists('processCompleteBands', $row) && is_array($row['processCompleteBands'])) { + return qdb_parse_process_complete_bands($row['processCompleteBands']); + } + return qdb_decode_process_complete_bands((string)($row['processCompleteBands'] ?? '[]')); +} + /** * Coach decision when set; otherwise the server-computed band. */ diff --git a/schema.sql b/schema.sql index 859d397..f9edb1f 100644 --- a/schema.sql +++ b/schema.sql @@ -225,6 +225,7 @@ CREATE TABLE IF NOT EXISTS scoring_profile ( yellowMin INTEGER NOT NULL DEFAULT 13, yellowMax INTEGER NOT NULL DEFAULT 36, redMin INTEGER NOT NULL DEFAULT 37, + processCompleteBands TEXT NOT NULL DEFAULT '[]', createdAt INTEGER NOT NULL DEFAULT 0, updatedAt INTEGER NOT NULL DEFAULT 0 ); diff --git a/website/css/style.css b/website/css/style.css index df89ae3..d6d7ea5 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -3548,6 +3548,105 @@ select:disabled { color: var(--primary-text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } +.scoring-profile-complete-bands { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border); +} +.scoring-profile-complete-label { + font-size: .8125rem; + font-weight: 600; + color: var(--text-secondary); +} +.scoring-profile-complete-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} +.scoring-process-complete-panel { + margin-top: 4px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-muted) 100%); + box-shadow: var(--shadow-inset); +} +.scoring-process-complete-hint { + margin: 0 0 14px; + font-size: .875rem; + line-height: 1.5; + color: var(--text-secondary); +} +.scoring-coach-band-panel .scoring-coach-band-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 16px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.scoring-coach-band-panel .scoring-profile-select, +.scoring-coach-band-panel .condition-qn-select { + width: 100%; + max-width: none; +} +.scoring-band-picker { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.scoring-band-picker-item { + position: relative; + display: inline-flex; + margin: 0; + cursor: pointer; + user-select: none; +} +.scoring-band-picker-item input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; + pointer-events: none; +} +.scoring-band-picker-item .band-badge { + cursor: pointer; + opacity: .45; + filter: grayscale(.25); + transition: opacity .15s ease, transform .15s ease, box-shadow .15s ease, filter .15s ease; +} +.scoring-band-picker-item:hover .band-badge { + opacity: .72; + filter: none; +} +.scoring-band-picker-item input:checked + .band-badge { + opacity: 1; + filter: none; + transform: translateY(-1px); + box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--primary); +} +.scoring-band-picker-item input:focus-visible + .band-badge { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +.scoring-band-picker-item input:disabled + .band-badge { + cursor: not-allowed; + opacity: .35; +} +@media (max-width: 720px) { + .scoring-coach-band-panel .scoring-coach-band-fields { + grid-template-columns: 1fr; + } +} .scoring-profile-member-count { margin: 12px 0 8px; font-size: .8rem; diff --git a/website/js/condition-editor.js b/website/js/condition-editor.js index e900cea..4a5be35 100644 --- a/website/js/condition-editor.js +++ b/website/js/condition-editor.js @@ -6,10 +6,17 @@ const CONDITION_MODES = [ { value: 'always', label: 'Always available' }, { value: 'requires', label: 'Requires other questionnaires completed' }, { value: 'requires_answer', label: 'Requires completions + answer check' }, + { value: 'requires_coach_band', label: 'Requires coach category (band)' }, { value: 'any_of', label: 'Any of these rules (OR)' }, { value: 'custom', label: 'Custom JSON (advanced)' }, ]; +const SCORING_BANDS = [ + { value: 'green', label: 'Green' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'red', label: 'Red' }, +]; + const CHOICE_LAYOUT_TYPES = new Set([ 'radio_question', 'multi_check_box_question', @@ -156,6 +163,21 @@ export function parseConditionForm(conditionJson, questionnaireId) { if (obj.alwaysAvailable === true) { return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' }; } + if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object') { + const band = obj.requiresCoachBand; + const allowed = Array.isArray(band.allowed) + ? band.allowed.filter(Boolean).map(v => String(v).toLowerCase()) + : []; + return { + mode: 'requires_coach_band', + requires: asRequiresList(obj), + coachBandProfileID: (band.profileID || '').trim(), + coachBandAllowed: allowed, + messageKey, + germanLabel: '', + customJson: '', + }; + } if (Array.isArray(obj.anyOf) && obj.anyOf.length) { return { mode: 'any_of', @@ -219,6 +241,20 @@ export function buildConditionJson(form, selfQuestionnaireId = '') { throw new Error('Add at least one rule for "Any of these rules"'); } obj = { anyOf: rules }; + } else if (form.mode === 'requires_coach_band') { + obj = {}; + if (form.requires?.length) { + obj.requiresCompleted = [...form.requires]; + } + const profileID = (form.coachBandProfileID || '').trim(); + const allowed = (form.coachBandAllowed || []).filter(Boolean); + if (!profileID) { + throw new Error('Select a scoring profile for the coach category rule'); + } + if (!allowed.length) { + throw new Error('Select at least one allowed coach category (band)'); + } + obj.requiresCoachBand = { profileID, allowed: [...allowed] }; } else { obj = buildRule({ type: form.mode, @@ -251,6 +287,61 @@ export function buildConditionJson(form, selfQuestionnaireId = '') { return JSON.stringify(obj); } +function bandCheckboxesHTML(selected, editable) { + const dis = editable ? '' : ' disabled'; + const sel = new Set((selected || []).map(b => String(b).toLowerCase())); + return `
+ ${SCORING_BANDS.map(b => { + const checked = sel.has(b.value) ? ' checked' : ''; + return ``; + }).join('')} +
`; +} + +function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) { + const dis = editable ? '' : ' disabled'; + const list = (scoringProfiles || []).filter(p => p.profileID); + if (!list.length) { + return '

No active scoring profiles. Create one under Questionnaires → Scoring profiles.

'; + } + const options = [''] + .concat(list.map(p => { + const id = p.profileID; + const sel = selectedId === id ? ' selected' : ''; + const label = (p.name || '').trim() || 'Unnamed profile'; + return ``; + })); + return ``; +} + +function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, editable) { + return ` +
+ + ${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)} +
+
+ + ${scoringProfileSelectHTML(form.coachBandProfileID || '', scoringProfiles, editable)} +
+
+ + ${bandCheckboxesHTML(form.coachBandAllowed || [], editable)} +

Unlocks only after the coach sets one of these categories during score review.

+
+
+
`; +} + +function readCoachBandAllowed() { + const root = document.getElementById('condCoachBands'); + if (!root) return []; + return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value); +} + function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) { const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId); if (!list.length) { @@ -377,6 +468,7 @@ export function renderConditionEditorHTML(form, { questionnaireId, allQuestionnaires, questionsByQuestionnaire, + scoringProfiles = [], germanLabel, editable, }) { @@ -412,6 +504,10 @@ export function renderConditionEditorHTML(form, { }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ ${coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, questionnaireId, editable)} +
+

Unlock if any one rule matches.

@@ -502,6 +598,13 @@ export function readConditionFormFromDOM() { return form; } + if (mode === 'requires_coach_band') { + form.requires = readRequires('condRequires'); + form.coachBandProfileID = document.getElementById('condCoachBandProfile')?.value?.trim() || ''; + form.coachBandAllowed = readCoachBandAllowed(); + return form; + } + if (mode === 'any_of') { form.rules = []; document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => { @@ -572,6 +675,7 @@ export function mountConditionEditor(container, { questionnaireId, allQuestionnaires, questionsByQuestionnaire = {}, + scoringProfiles = [], conditionJson, germanLabel, editable, @@ -586,6 +690,7 @@ export function mountConditionEditor(container, { questionnaireId, allQuestionnaires, questionsByQuestionnaire, + scoringProfiles, germanLabel: label, editable, } diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index f576d8c..0d01394 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -938,6 +938,13 @@ async function initConditionEditor(condJson) { if (!mount) return; mount.innerHTML = '
'; await loadQuestionsByQuestionnaire(); + let scoringProfiles = []; + try { + const data = await apiGet('scoring_profiles.php'); + scoringProfiles = (data.profiles || []).filter(p => p.isActive !== 0 && p.isActive !== false); + } catch { + scoringProfiles = []; + } const qid = questionnaire?.questionnaireID || 'new'; const parsed = parseConditionForm(condJson, qid); const germanLabel = parsed.messageKey @@ -947,6 +954,7 @@ async function initConditionEditor(condJson) { questionnaireId: qid, allQuestionnaires, questionsByQuestionnaire, + scoringProfiles, conditionJson: condJson, germanLabel, editable: canEdit(), diff --git a/website/js/pages/questionnaires.js b/website/js/pages/questionnaires.js index 767c536..3c0c13f 100644 --- a/website/js/pages/questionnaires.js +++ b/website/js/pages/questionnaires.js @@ -463,6 +463,54 @@ function bandRangesEditorHTML(bands) {
`; } +const SCORING_BAND_OPTIONS = [ + { value: 'green', label: 'Green' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'red', label: 'Red' }, +]; + +function processCompleteBandsSummaryHTML(profile) { + const bands = (profile?.processCompleteBands || []) + .map(b => String(b).toLowerCase()) + .filter(Boolean); + if (!bands.length) return ''; + const chips = bands.map(b => { + const label = SCORING_BAND_OPTIONS.find(o => o.value === b)?.label || b; + return `${esc(label)}`; + }).join(''); + return ` +
+ Process complete when coach sets + ${chips} +
`; +} + +function processCompleteBandsEditorHTML(selected, editable) { + const dis = editable ? '' : ' disabled'; + const sel = new Set((selected || []).map(b => String(b).toLowerCase())); + return ` +
+

Process complete

+

+ When the coach sets one of these categories during score review, the app shows the process-complete dialog for that client. +

+
+ ${SCORING_BAND_OPTIONS.map(b => { + const checked = sel.has(b.value) ? ' checked' : ''; + return ``; + }).join('')} +
+
`; +} + +function readProcessCompleteBandsFromModal(overlay) { + return [...(overlay.querySelectorAll('#spProcessCompleteBands input[type=checkbox]:checked') || [])] + .map(cb => cb.value); +} + function renderScoringProfilesSection(profiles, questionnaires) { const panel = document.getElementById('scoringProfilesPanel'); if (!panel) return; @@ -542,6 +590,7 @@ function scoringProfileCardHTML(profile, questionnaires) {
${profile.description ? `

${esc(profile.description)}

` : ''}

${bandSummary(profile)}

+ ${processCompleteBandsSummaryHTML(profile)}
${canEdit() ? `
@@ -584,6 +633,7 @@ function openScoringProfileModal(profile, questionnaires, allProfiles) {
${bandRangesEditorHTML(initialBands)} + ${processCompleteBandsEditorHTML(profile?.processCompleteBands || [], canEdit())}

Questionnaires

Add questionnaires and set weight. List order is the display order.

@@ -724,6 +774,7 @@ function openScoringProfileModal(profile, questionnaires, allProfiles) { description, isActive, ...bands, + processCompleteBands: readProcessCompleteBandsFromModal(overlay), questionnaires: members.map((m, idx) => ({ questionnaireID: m.questionnaireID, weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,