/** * Questionnaire availability condition UI (same JSON schema as the Android app). */ 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', 'glass_scale_question', ]); export function defaultConditionMessageKey(questionnaireId) { const id = (questionnaireId || 'new').replace(/^questionnaire_/, ''); const slug = id.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'new'; const normalized = /^[a-zA-Z]/.test(slug) ? slug : `k_${slug}`; return `questionnaire_unlock_${normalized}`; } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; } function parseQuestionConfig(q) { try { return JSON.parse(q?.configJson || '{}'); } catch { return {}; } } /** Stable id stored in conditionJson.questionId (matches app endsWith check). */ export function conditionQuestionId(q) { const cfg = parseQuestionConfig(q); const key = (q?.questionKey || cfg.questionKey || '').trim(); if (key) return key; if (q?.localId) return q.localId; const parts = (q?.questionID || '').split('__'); return parts[parts.length - 1] || ''; } function conditionQuestionLabel(q) { const id = conditionQuestionId(q); const text = (q?.defaultText || '').trim(); if (!text) return id; const short = text.length > 48 ? `${text.slice(0, 45)}…` : text; return `${id} — ${short}`; } function questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) { if (!questionnaireId) return []; return questionsByQuestionnaire?.[questionnaireId] || []; } function findQuestion(questionsByQuestionnaire, questionnaireId, questionId) { if (!questionnaireId || !questionId) return null; return questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) .find(q => { const cid = conditionQuestionId(q); const local = q.localId || (q.questionID || '').split('__').pop(); return questionId === cid || questionId === local || questionId === q.questionID; }) || null; } function questionMatchesSelection(q, selectedQuestionId) { if (!selectedQuestionId) return false; const cid = conditionQuestionId(q); const local = q.localId || (q.questionID || '').split('__').pop(); return selectedQuestionId === cid || selectedQuestionId === local || selectedQuestionId === q.questionID; } function asRequiresList(obj) { if (!obj) return []; if (Array.isArray(obj.requiresCompleted)) { return obj.requiresCompleted.filter(Boolean).map(String); } if (typeof obj.requiresCompleted === 'string' && obj.requiresCompleted) { return [obj.requiresCompleted]; } return []; } function hasQuestionCheck(obj) { return Boolean( obj?.questionnaire?.trim() && obj?.questionId?.trim() && obj?.operator?.trim() && obj?.value != null && String(obj.value).trim() !== '' ); } function filterRequires(requires, selfId) { if (!requires?.length) return []; if (!selfId) return [...requires]; return requires.filter(id => id !== selfId); } /** Answer checks cannot reference the questionnaire being edited. */ function sanitizeAnswerRule(rule, selfId) { if (!selfId || rule.questionnaire !== selfId) { return { ...rule, requires: filterRequires(rule.requires, selfId) }; } return { ...rule, requires: filterRequires(rule.requires, selfId), questionnaire: '', questionId: '', value: '', }; } function parseRule(obj, selfId = '') { if (!obj || typeof obj !== 'object') { return { type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '' }; } if (obj.alwaysAvailable) { return { type: 'always' }; } const rule = { type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires', requires: asRequiresList(obj), questionnaire: obj.questionnaire || '', questionId: obj.questionId || '', operator: obj.operator === '!=' ? '!=' : '==', value: obj.value != null ? String(obj.value) : '', }; return sanitizeAnswerRule(rule, selfId); } export function parseConditionForm(conditionJson, questionnaireId) { const raw = (conditionJson || '{}').trim() || '{}'; let obj; try { obj = JSON.parse(raw); } catch { return { mode: 'custom', customJson: raw, messageKey: defaultConditionMessageKey(questionnaireId), germanLabel: '', }; } const messageKey = (obj.messageKey || '').trim() || defaultConditionMessageKey(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', rules: obj.anyOf.map(r => parseRule(r, questionnaireId)).filter(r => r.type !== 'always'), messageKey, germanLabel: '', customJson: '', }; } const rule = parseRule(obj, questionnaireId); return { mode: rule.type === 'always' ? 'always' : rule.type, requires: rule.requires, questionnaire: rule.questionnaire, questionId: rule.questionId, operator: rule.operator, value: rule.value, messageKey, germanLabel: '', customJson: '', }; } function buildRule(rule) { if (rule.type === 'always') { return { alwaysAvailable: true }; } const out = {}; if (rule.requires?.length) { out.requiresCompleted = [...rule.requires]; } if (rule.type === 'requires_answer') { out.questionnaire = (rule.questionnaire || '').trim(); out.questionId = (rule.questionId || '').trim(); out.operator = rule.operator === '!=' ? '!=' : '=='; out.value = String(rule.value ?? '').trim(); } return out; } function assertAnswerNotSelf(questionnaireId, selfId) { if (selfId && questionnaireId === selfId) { throw new Error('Answer check must use a different questionnaire (not this one)'); } } export function buildConditionJson(form, selfQuestionnaireId = '') { if (form.mode === 'custom') { const raw = (form.customJson || '{}').trim() || '{}'; JSON.parse(raw); return raw; } let obj; if (form.mode === 'always') { obj = { alwaysAvailable: true }; } else if (form.mode === 'any_of') { const rules = (form.rules || []).map(buildRule).filter(r => Object.keys(r).length); if (!rules.length) { 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, requires: form.requires || [], questionnaire: form.questionnaire, questionId: form.questionId, operator: form.operator, value: form.value, }); if (form.mode === 'requires' && !obj.requiresCompleted?.length) { throw new Error('Select at least one required questionnaire'); } if (form.mode === 'requires_answer') { if (!obj.questionnaire) { throw new Error('Select the questionnaire for the answer check'); } if (!obj.questionId) { throw new Error('Select the question for the answer check'); } if (!obj.value) { throw new Error('Enter or select the expected answer value'); } } } const messageKey = (form.messageKey || '').trim(); if (form.mode !== 'always' && messageKey) { obj.messageKey = messageKey; } 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) { return '

No other questionnaires to require.

'; } return `
${list.map(q => { const checked = selected.includes(q.questionnaireID) ? ' checked' : ''; return ``; }).join('')}
`; } function questionnaireSelectHTML(prefix, selectedId, allQuestionnaires, selfId, editable) { const dis = editable ? '' : ' disabled'; const list = (allQuestionnaires || []).filter( q => q.questionnaireID && q.questionnaireID !== selfId ); const options = [''] .concat(list.map(q => { const id = q.questionnaireID; const sel = selectedId === id ? ' selected' : ''; const label = q.name ? `${q.name}` : id; const suffix = q.name && q.name !== id ? ` (${id})` : ''; return ``; })); return ``; } function questionSelectHTML(prefix, questionnaireId, selectedQuestionId, questionsByQuestionnaire, editable) { const dis = editable ? '' : ' disabled'; const questions = questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId); if (!questionnaireId) { return ``; } const options = ['']; for (const q of questions) { const val = conditionQuestionId(q); const sel = questionMatchesSelection(q, selectedQuestionId) ? ' selected' : ''; options.push(``); } const known = questions.some(q => questionMatchesSelection(q, selectedQuestionId)); if (selectedQuestionId && !known) { options.push( `` ); } if (!questions.length) { options.push(''); } return ``; } function answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable) { const dis = editable ? '' : ' disabled'; const q = findQuestion(questionsByQuestionnaire, rule.questionnaire, rule.questionId); const options = q?.answerOptions || []; const hasChoices = q && CHOICE_LAYOUT_TYPES.has(q.type) && options.length > 0; if (hasChoices) { const opts = ['']; for (const opt of options) { const val = (opt.optionKey || opt.defaultText || '').trim(); if (!val) continue; const label = opt.labelGerman || opt.defaultText || val; const sel = rule.value === val ? ' selected' : ''; opts.push(``); } if (rule.value && !options.some(o => (o.optionKey || o.defaultText || '').trim() === rule.value)) { opts.push(``); } return ``; } return ``; } function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { const dis = editable ? '' : ' disabled'; return `
${questionnaireSelectHTML(prefix, rule.questionnaire, allQuestionnaires, selfId, editable)}

Must be a different questionnaire than this one.

${questionSelectHTML(prefix, rule.questionnaire, rule.questionId, questionsByQuestionnaire, editable)}
${answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable)}
`; } function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { const showAnswer = rule.type === 'requires_answer'; return `
${requiresCheckboxes(`${prefix}Requires`, rule.requires || [], allQuestionnaires, selfId, editable)} ${showAnswer ? ` ${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)} ` : ''}
`; } export function renderConditionEditorHTML(form, { questionnaireId, allQuestionnaires, questionsByQuestionnaire, scoringProfiles = [], germanLabel, editable, }) { const dis = editable ? '' : ' disabled'; const modeOptions = CONDITION_MODES.map(m => `` ).join(''); const rules = form.rules?.length ? form.rules : [{ type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', }]; return `

Availability

${singleRuleHTML('cond', { type: 'requires', requires: form.requires || [] }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
${singleRuleHTML('cond', { type: 'requires_answer', requires: form.requires || [], questionnaire: form.questionnaire || '', questionId: form.questionId || '', operator: form.operator || '==', value: form.value || '', }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
${coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, questionnaireId, editable)}

Unlock if any one rule matches.

${rules.map((rule, i) => `
Rule ${i + 1} ${editable && rules.length > 1 ? `` : ''}
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
`).join('')}
${editable ? '' : ''}

Shown on the app when locked. Also listed under Translations → App strings.

`; } function readRequires(containerId) { const root = document.getElementById(containerId); if (!root) return []; return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value); } function readAnswerPrefix(prefix) { return { questionnaire: document.getElementById(`${prefix}Questionnaire`)?.value?.trim() || '', questionId: document.getElementById(`${prefix}QuestionId`)?.value?.trim() || '', operator: document.getElementById(`${prefix}Operator`)?.value || '==', value: document.getElementById(`${prefix}Value`)?.value?.trim() ?? '', }; } export function readConditionFormFromDOM() { const mode = document.getElementById('conditionMode')?.value || 'always'; const form = { mode, messageKey: '', germanLabel: '', customJson: '' }; if (mode === 'custom') { form.customJson = document.getElementById('conditionCustomJson')?.value || '{}'; try { const obj = JSON.parse(form.customJson); form.messageKey = (obj.messageKey || '').trim(); } catch { /* validated on save */ } form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; return form; } if (mode === 'always') { return form; } form.messageKey = document.getElementById('conditionMessageKey')?.value?.trim() || ''; form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; if (mode === 'requires') { form.requires = readRequires('condRequires'); return form; } if (mode === 'requires_answer') { form.requires = readRequires('condRequires'); Object.assign(form, readAnswerPrefix('cond')); 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) => { const type = el.querySelector('.anyof-rule-type')?.value || 'requires'; const requires = readRequires(`anyof${i}Requires`); const rule = { type, requires, ...readAnswerPrefix(`anyof${i}`) }; form.rules.push(rule); }); } return form; } function wireConditionSelects(container, { questionnaireId, onChange }) { container.querySelectorAll('.condition-qn-select').forEach(sel => { sel.addEventListener('change', () => { const prefix = sel.id.replace(/Questionnaire$/, ''); const form = readConditionFormFromDOM(); if (prefix === 'cond' && form.mode === 'requires_answer') { const answer = readAnswerPrefix('cond'); answer.questionnaire = sel.value; if (answer.questionnaire !== form.questionnaire) { answer.questionId = ''; answer.value = ''; } form.questionnaire = answer.questionnaire; form.questionId = answer.questionId; form.value = answer.value; } else if (form.rules) { const idx = parseInt(prefix.replace(/^anyof/, ''), 10); if (!Number.isNaN(idx) && form.rules[idx]) { const prev = form.rules[idx].questionnaire; form.rules[idx].questionnaire = sel.value; if (sel.value !== prev) { form.rules[idx].questionId = ''; form.rules[idx].value = ''; } } } onChange(form); }); }); container.querySelectorAll('.condition-question-select').forEach(sel => { sel.addEventListener('change', () => { const prefix = sel.id.replace(/QuestionId$/, ''); const form = readConditionFormFromDOM(); const qId = sel.value; if (prefix === 'cond' && form.mode === 'requires_answer') { form.questionId = qId; form.value = ''; } else if (form.rules) { const idx = parseInt(prefix.replace(/^anyof/, ''), 10); if (!Number.isNaN(idx) && form.rules[idx]) { form.rules[idx].questionId = qId; form.rules[idx].value = ''; } } onChange(form); }); }); } /** * Mount editor into container; returns { readForm, buildJson }. */ export function mountConditionEditor(container, { questionnaireId, allQuestionnaires, questionsByQuestionnaire = {}, scoringProfiles = [], conditionJson, germanLabel, editable, }) { let form = parseConditionForm(conditionJson, questionnaireId); let label = germanLabel || ''; const render = () => { container.innerHTML = renderConditionEditorHTML( { ...form, germanLabel: label }, { questionnaireId, allQuestionnaires, questionsByQuestionnaire, scoringProfiles, germanLabel: label, editable, } ); wire(); }; const wire = () => { const modeEl = document.getElementById('conditionMode'); modeEl?.addEventListener('change', () => { form = readConditionFormFromDOM(); form.mode = modeEl.value; if (form.mode === 'always') { form.messageKey = ''; } else if (!form.messageKey) { form.messageKey = defaultConditionMessageKey(questionnaireId); } render(); }); document.getElementById('addAnyOfRule')?.addEventListener('click', () => { form = readConditionFormFromDOM(); form.mode = 'any_of'; form.rules = form.rules || []; form.rules.push({ type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', }); label = document.getElementById('conditionGermanLabel')?.value || label; render(); }); container.querySelectorAll('.remove-anyof-rule').forEach(btn => { btn.addEventListener('click', () => { form = readConditionFormFromDOM(); label = document.getElementById('conditionGermanLabel')?.value || label; const idx = parseInt(btn.closest('.condition-anyof-rule')?.dataset.ruleIndex || '-1', 10); form.rules.splice(idx, 1); render(); }); }); container.querySelectorAll('.anyof-rule-type').forEach(sel => { sel.addEventListener('change', () => { form = readConditionFormFromDOM(); label = document.getElementById('conditionGermanLabel')?.value || label; const idx = parseInt(sel.dataset.ruleIndex || '0', 10); if (form.rules?.[idx]) { form.rules[idx].type = sel.value; } render(); }); }); wireConditionSelects(container, { questionnaireId, onChange: (updated) => { form = updated; label = document.getElementById('conditionGermanLabel')?.value || label; render(); }, }); }; render(); return { readForm: () => { form = readConditionFormFromDOM(); label = document.getElementById('conditionGermanLabel')?.value?.trim() || label; return { ...form, germanLabel: label }; }, buildJson: () => buildConditionJson(readConditionFormFromDOM(), questionnaireId), }; }