diff --git a/api/export.php b/api/export.php index edb39f7..257b0b9 100644 --- a/api/export.php +++ b/api/export.php @@ -58,12 +58,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config $qStmt->execute([':id' => $qnID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questionIDs = array_column($questions, 'questionID'); -$questionColumnLabels = []; -foreach ($questions as &$q) { - $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; - $questionColumnLabels[$q['questionID']] = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID); -} -unset($q); +$resultColumns = qdb_results_export_columns($questions, $qnID); // Build answer-option lookup for resolving display text $optionTextMap = []; @@ -121,10 +116,10 @@ foreach ($clients as $c) { $answerMap = []; foreach ($answers as $a) $answerMap[$a['questionID']] = $a; - foreach ($questions as $q) { - $qid = $q['questionID']; + foreach ($resultColumns as $col) { + $qid = $col['questionID']; $a = $answerMap[$qid] ?? null; - $row[$questionColumnLabels[$qid]] = qdb_format_client_answer_display($q, $a, $optionTextMap); + $row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap); } $rows[] = $row; } @@ -149,8 +144,8 @@ if (!empty($rows)) { } } else { $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; - foreach ($questions as $q) { - $header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; + foreach ($resultColumns as $col) { + $header[] = $col['header']; } fputcsv($out, $header); } diff --git a/common.php b/common.php index 675a754..769e413 100644 --- a/common.php +++ b/common.php @@ -532,6 +532,73 @@ function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabel return json_encode($data, JSON_UNESCAPED_UNICODE); } +/** Single symptom value from glass-scale JSON stored on the parent question row. */ +function qdb_glass_symptom_answer_value(?string $json, string $symptomKey): string +{ + $symptomKey = trim($symptomKey); + if ($symptomKey === '' || $json === null || trim($json) === '') { + return ''; + } + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + return ''; + } + return trim((string)($decoded[$symptomKey] ?? '')); +} + +/** + * Flat columns for results / CSV: one column per glass-scale symptom. + * + * @return list + */ +function qdb_results_export_columns(array $questions, string $qnID): array +{ + $columns = []; + foreach ($questions as $q) { + $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; + $type = $q['type'] ?? ''; + if ($type === 'glass_scale_question') { + $symptoms = $cfg['symptoms'] ?? []; + if (is_array($symptoms) && $symptoms !== []) { + foreach ($symptoms as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk === '') { + continue; + } + $columns[] = [ + 'header' => $sk, + 'questionID' => $q['questionID'], + 'symptomKey' => $sk, + 'kind' => 'glass_symptom', + 'question' => $q, + ]; + } + continue; + } + } + $columns[] = [ + 'header' => qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID), + 'questionID' => $q['questionID'], + 'symptomKey' => null, + 'kind' => 'question', + 'question' => $q, + ]; + } + return $columns; +} + +/** Cell value for one export/results column (including glass symptom columns). */ +function qdb_results_column_cell_value(array $column, ?array $answerRow, array $optionTextMap): string +{ + if (($column['kind'] ?? '') === 'glass_symptom') { + return qdb_glass_symptom_answer_value( + $answerRow['freeTextValue'] ?? null, + (string)($column['symptomKey'] ?? '') + ); + } + return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap); +} + /** Format one client_answer row for results table / CSV export. */ function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string { diff --git a/handlers/export.php b/handlers/export.php index 2a8ade5..92329f7 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -38,13 +38,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config $qStmt->execute([':id' => $qnID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questionIDs = array_column($questions, 'questionID'); -$questionColumnLabels = []; -foreach ($questions as &$q) { - $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; - $label = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID); - $questionColumnLabels[$q['questionID']] = $label; -} -unset($q); +$resultColumns = qdb_results_export_columns($questions, $qnID); $optionTextMap = []; foreach ($questionIDs as $qid) { @@ -99,10 +93,10 @@ foreach ($clients as $c) { $answerMap = []; foreach ($answers as $a) $answerMap[$a['questionID']] = $a; - foreach ($questions as $q) { - $qid = $q['questionID']; + foreach ($resultColumns as $col) { + $qid = $col['questionID']; $a = $answerMap[$qid] ?? null; - $row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap); + $row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap); } $rows[] = $row; } @@ -126,8 +120,8 @@ if (!empty($rows)) { } } else { $header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt']; - foreach ($questions as $q) { - $header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; + foreach ($resultColumns as $col) { + $header[] = $col['header']; } fputcsv($out, $header); } diff --git a/website/js/pages/results.js b/website/js/pages/results.js index f537be8..2872b76 100644 --- a/website/js/pages/results.js +++ b/website/js/pages/results.js @@ -1,9 +1,9 @@ import { apiGet } from '../api.js'; import { showToast } from '../app.js'; import { + buildResultColumns, isDevTestClientCode, - questionDisplayKey, - questionGermanLabel, + resultColumnCellValue, testDataRowClassForClient, } from '../test-data.js'; @@ -13,6 +13,7 @@ let sortCol = null; let sortDir = 'asc'; let allClients = []; let questionsDef = []; +let resultColumns = []; let questionnaireMeta = null; let filterCoach = ''; let filterSupervisor = ''; @@ -64,6 +65,7 @@ export async function resultsPage(params) { function renderResults() { const container = document.getElementById('resultsContent'); + resultColumns = buildResultColumns(questionsDef); // Collect unique coaches and statuses for filters const coachOptions = getCoachFilterOptions(); @@ -107,7 +109,7 @@ function renderResults() { Export CSV -

Question columns show stable keys (e.g. country_of_origin); hover for German text. Test rows (DEV-CL-*) have a yellow background.

+

Each question has its own column; glass-scale symptoms are separate columns. Hover headers for context. Test rows (DEV-CL-*) are highlighted.

@@ -121,8 +123,8 @@ function renderResults() { `; const qKeyList = document.getElementById('questionKeyList'); - qKeyList.innerHTML = questionsDef - .map(q => ``) + qKeyList.innerHTML = resultColumns + .map(col => ``) .join(''); document.getElementById('filterSupervisor').addEventListener('change', (e) => { @@ -202,18 +204,11 @@ function renderTableHead() { { key: 'sumPoints', label: 'Score' }, { key: 'completedAt', label: 'Completed' }, ]; - const questionCols = questionsDef.map(q => { - const qKey = questionDisplayKey(q); - const german = questionGermanLabel(q); - return { - key: `q_${q.questionID}`, - label: qKey, - title: german || qKey, - questionID: q.questionID, - questionKey: qKey, - }; - }); - const allCols = [...fixedCols, ...questionCols]; + const allCols = [...fixedCols, ...resultColumns.map(col => ({ + key: col.key, + label: col.label, + title: col.title, + }))]; head.innerHTML = allCols.map((col, idx) => { let cls = 'sortable'; @@ -389,19 +384,11 @@ function renderTableRows() { const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—'; const statusBadge = c.status ? `${esc(c.status)}` : '—'; - const questionCells = questionsDef.map(q => { - const ans = c.answers && c.answers[q.questionID]; - if (!ans) return '—'; - if (q.type === 'glass_scale_question') { - const text = formatGlassAnswer(ans.freeTextValue); - return `${esc(text || '—')}`; - } - if (ans.answerOptionID && optionMap[ans.answerOptionID]) { - return `${esc(optionMap[ans.answerOptionID])}`; - } - if (ans.freeTextValue) return `${esc(ans.freeTextValue)}`; - if (ans.numericValue !== null && ans.numericValue !== undefined) return `${ans.numericValue}`; - return '—'; + const questionCells = resultColumns.map(col => { + const ans = c.answers && c.answers[col.questionID]; + const val = resultColumnCellValue(col, ans, optionMap); + if (val == null || val === '') return '—'; + return `${esc(String(val))}`; }).join(''); return ` @@ -423,17 +410,16 @@ function getCellValue(client, key) { if (key === 'status') return client.status; if (key === 'sumPoints') return client.sumPoints; if (key === 'completedAt') return client.completedAt; - if (key.startsWith('q_')) { - const qid = key.substring(2); - const q = questionsDef.find(qq => qq.questionID === qid); - const ans = client.answers && client.answers[qid]; - if (!ans) return null; - if (q?.type === 'glass_scale_question') { - return formatGlassAnswer(ans.freeTextValue) || null; - } - if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue; - if (ans.freeTextValue) return ans.freeTextValue; - return ans.answerOptionID || null; + const col = resultColumns.find(c => c.key === key); + if (col) { + const ans = client.answers && client.answers[col.questionID]; + const optionMap = {}; + questionsDef.forEach(q => { + (q.answerOptions || []).forEach(o => { + optionMap[o.answerOptionID] = o.defaultText; + }); + }); + return resultColumnCellValue(col, ans, optionMap); } return null; } @@ -448,23 +434,17 @@ function exportCSV() { }); const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt', - ...questionsDef.map(q => questionDisplayKey(q))]; + ...resultColumns.map(col => col.label)]; const rows = clients.map(c => { const base = [ c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '', c.completedAt ? new Date(c.completedAt * 1000).toISOString() : '' ]; - const answerCols = questionsDef.map(q => { - const ans = c.answers && c.answers[q.questionID]; - if (!ans) return ''; - if (q.type === 'glass_scale_question') { - return formatGlassAnswer(ans.freeTextValue); - } - if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID]; - if (ans.freeTextValue) return ans.freeTextValue; - if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue; - return ''; + const answerCols = resultColumns.map(col => { + const ans = c.answers && c.answers[col.questionID]; + const val = resultColumnCellValue(col, ans, optionMap); + return val != null ? val : ''; }); return [...base, ...answerCols]; }); @@ -491,22 +471,6 @@ function csvEsc(val) { return s; } -function formatGlassAnswer(raw) { - if (!raw) return ''; - try { - const o = JSON.parse(raw); - if (o && typeof o === 'object' && !Array.isArray(o)) { - return Object.entries(o) - .filter(([, v]) => v != null && String(v).trim() !== '') - .map(([k, v]) => `${k}: ${v}`) - .join('; '); - } - } catch { - /* plain text fallback */ - } - return String(raw); -} - function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; diff --git a/website/js/test-data.js b/website/js/test-data.js index 9e170db..3e03823 100644 --- a/website/js/test-data.js +++ b/website/js/test-data.js @@ -59,6 +59,80 @@ export function questionGermanLabel(q) { return ''; } +/** One symptom value from parent glass-scale JSON answer. */ +export function glassSymptomAnswerValue(raw, symptomKey) { + if (!raw || !symptomKey) return ''; + try { + const o = JSON.parse(raw); + if (o && typeof o === 'object' && !Array.isArray(o)) { + const v = o[symptomKey]; + return v != null ? String(v).trim() : ''; + } + } catch { + /* ignore */ + } + return ''; +} + +/** + * Flat result/export columns: glass_scale questions → one column per symptom. + */ +export function buildResultColumns(questions) { + const cols = []; + for (const q of questions || []) { + const cfg = parseQuestionConfig(q); + const symptoms = cfg.symptoms; + if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) { + const parentKey = questionDisplayKey(q); + for (const sk of symptoms) { + const symptomKey = String(sk).trim(); + if (!symptomKey) continue; + cols.push({ + key: `glass_${q.questionID}_${symptomKey}`, + questionID: q.questionID, + symptomKey, + label: symptomKey, + title: parentKey && parentKey !== symptomKey + ? `${parentKey} · ${symptomKey}` + : symptomKey, + isGlassSymptom: true, + question: q, + }); + } + continue; + } + const label = questionDisplayKey(q); + cols.push({ + key: `q_${q.questionID}`, + questionID: q.questionID, + symptomKey: null, + label, + title: questionGermanLabel(q) || label, + isGlassSymptom: false, + question: q, + }); + } + return cols; +} + +/** Display text for one results table cell. */ +export function resultColumnCellValue(col, answer, optionMap = {}) { + if (!answer) return null; + if (col.isGlassSymptom) { + const v = glassSymptomAnswerValue(answer.freeTextValue, col.symptomKey); + return v || null; + } + const q = col.question; + if (answer.answerOptionID && optionMap[answer.answerOptionID]) { + return optionMap[answer.answerOptionID]; + } + if (answer.freeTextValue) return answer.freeTextValue; + if (answer.numericValue !== null && answer.numericValue !== undefined) { + return answer.numericValue; + } + return null; +} + /** header_order.json column id → question key (questionnaire_x-y → y). */ export function headerColumnQuestionKey(headerId) { const id = String(headerId ?? '');