implemented scoring system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-08 19:10:23 +02:00
parent 4589be119c
commit 63adef79df
19 changed files with 2873 additions and 36 deletions

View File

@ -33,6 +33,14 @@ const LAYOUT_TYPES = [
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
const GLASS_SCORE_LEVELS = [
{ key: 'never_glass', label: 'Never' },
{ key: 'little_glass', label: 'Little' },
{ key: 'moderate_glass', label: 'Moderate' },
{ key: 'much_glass', label: 'Much' },
{ key: 'extreme_glass', label: 'Extreme' },
];
let questionnaire = null;
let questions = [];
let isNew = false;
@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) {
return q.glassSymptoms.map(r => ({
key: (r.key || '').trim(),
labelDe: (r.labelDe || '').trim(),
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
}));
}
const config = parseConfig(q);
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' }));
return (config.symptoms || []).map(key => ({
key: String(key).trim(),
labelDe: '',
scoreLevels: defaultGlassScoreLevels(),
}));
}
function defaultGlassScoreLevels() {
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
}
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
const min = Number(config.range?.min ?? 0);
const max = Number(config.range?.max ?? 100);
const step = Math.max(1, Number(config.step ?? 1));
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const ruleMap = {};
for (const r of scoreRules || []) {
ruleMap[r.levelKey] = r.points;
}
const values = [];
for (let v = lo; v <= hi; v += step) values.push(v);
if (!values.length) values.push(lo);
const rows = values.map(v => `
<div class="value-score-row">
<span class="value-score-label">${v}</span>
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
</div>`).join('');
return `
<div class="value-score-rules" id="${prefix}_value_scores">
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
<div class="value-score-grid">${rows}</div>
</div>`;
}
function readValueScoreRulesFromForm(prefix) {
const container = document.getElementById(`${prefix}_value_scores`);
if (!container) return [];
const rules = [];
container.querySelectorAll('.value-score-pts').forEach(inp => {
rules.push({
scopeKey: '',
levelKey: String(inp.dataset.value),
points: parseInt(inp.value || '0', 10) || 0,
});
});
return rules;
}
function wireValueScoreRules(prefix) {
const minInp = document.getElementById(`${prefix}_rangeMin`);
const maxInp = document.getElementById(`${prefix}_rangeMax`);
const stepInp = document.getElementById(`${prefix}_step`);
const rerender = () => {
const section = document.getElementById(`${prefix}_value_scores`);
if (!section) return;
const config = {
range: {
min: parseInt(minInp?.value || '0', 10),
max: parseInt(maxInp?.value || '100', 10),
},
step: parseInt(stepInp?.value || '1', 10) || 1,
};
const existing = readValueScoreRulesFromForm(prefix);
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
};
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
}
function suggestSymptomKey(label) {
@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) {
}
function glassSymptomRowHTML(prefix, row, idx) {
const levels = row.scoreLevels || defaultGlassScoreLevels();
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
<div class="glass-score-cell">
<label>${esc(l.label)}</label>
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
</div>`).join('');
return `
<li class="glass-symptom-row" data-idx="${idx}">
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
</div>
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">&#x2716;</button>
</div>
<div class="glass-score-grid">${ptsRow}</div>
</li>`;
}
@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) {
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
if (key || labelDe) rows.push({ key, labelDe });
const scoreLevels = {};
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
});
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
});
return rows;
}
@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) {
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
const rows = collectRows();
rows.push({ key: '', labelDe: '' });
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
renderList(rows);
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
lastKey?.focus();
@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) {
break;
}
case 'value_spinner':
case 'slider_question':
case 'slider_question': {
const scoreRules = opts.scoreRules || [];
html = `
<div class="form-row">
<div class="form-group">
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
<label>Unit label (optional)</label>
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
</div>` : ''}
</div>`;
</div>
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
break;
}
case 'date_spinner': {
const notBefore = config.constraints?.notBefore || '';
const notAfter = config.constraints?.notAfter || '';
@ -694,11 +786,6 @@ function renderEditor() {
<label>Order index</label>
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
<label class="checkbox-label">
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
</label>
</div>
<div class="form-group">
<label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
@ -838,7 +925,7 @@ async function saveMeta() {
const version = document.getElementById('metaVersion').value.trim();
const state = document.getElementById('metaState').value;
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
const showPoints = 0;
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
let conditionJson = '{}';
let conditionForm = null;
@ -971,6 +1058,7 @@ function showAddQuestionForm() {
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
wireStringSpinnerOtherToggle('aq_cfg');
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
}
@ -1060,6 +1148,9 @@ function showAddQuestionForm() {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
postBody.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
@ -1203,7 +1294,10 @@ function renderQuestionBody(q) {
</label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}">
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
glassSymptoms: glassSymptomsRowsFromQuestion(q),
scoreRules: q.scoreRules || [],
})}
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
payload.glassSymptoms = glassSymptoms;
}
if (type === 'slider_question' || type === 'value_spinner') {
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
}
if (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
@ -1291,15 +1388,19 @@ function renderQuestionBody(q) {
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
const opts = newLayout === 'glass_scale_question'
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
: {};
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
? { scoreRules: q.scoreRules || [] }
: {};
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
configFormHTML(newLayout, {}, cfgPrefix, opts);
wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
});
wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));