diff --git a/api/export.php b/api/export.php index 8fb280f..edb39f7 100644 --- a/api/export.php +++ b/api/export.php @@ -54,10 +54,16 @@ if (!$questionnaire) { } // Questions ordered -$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); +$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); $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); // Build answer-option lookup for resolving display text $optionTextMap = []; @@ -118,7 +124,7 @@ foreach ($clients as $c) { foreach ($questions as $q) { $qid = $q['questionID']; $a = $answerMap[$qid] ?? null; - $row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap); + $row[$questionColumnLabels[$qid]] = qdb_format_client_answer_display($q, $a, $optionTextMap); } $rows[] = $row; } @@ -143,7 +149,9 @@ if (!empty($rows)) { } } else { $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; - foreach ($questions as $q) $header[] = $q['defaultText']; + foreach ($questions as $q) { + $header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; + } fputcsv($out, $header); } fclose($out); diff --git a/common.php b/common.php index 50870d1..675a754 100644 --- a/common.php +++ b/common.php @@ -893,6 +893,12 @@ function qdb_question_local_id(string $questionID, ?string $questionnaireID = nu return $pos !== false ? substr($questionID, $pos + 2) : $questionID; } +/** Column / export header: stable question key, else local id (q1, q2, …). */ +function qdb_question_column_label(array $config, string $defaultText, string $questionID, ?string $questionnaireID = null): string { + $key = qdb_question_key($config, $defaultText); + return $key !== '' ? $key : qdb_question_local_id($questionID, $questionnaireID); +} + function qdb_make_question_id(string $questionnaireID, string $localId): string { return $questionnaireID . '__' . $localId; } diff --git a/handlers/export.php b/handlers/export.php index 1546946..2a8ade5 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -34,10 +34,17 @@ if (!$questionnaire) { json_error('NOT_FOUND', 'Questionnaire not found', 404); } -$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); +$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); $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); $optionTextMap = []; foreach ($questionIDs as $qid) { @@ -119,7 +126,9 @@ if (!empty($rows)) { } } else { $header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt']; - foreach ($questions as $q) $header[] = $q['defaultText']; + foreach ($questions as $q) { + $header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; + } fputcsv($out, $header); } fclose($out); diff --git a/handlers/results.php b/handlers/results.php index b05d4f6..c50943f 100644 --- a/handlers/results.php +++ b/handlers/results.php @@ -34,6 +34,8 @@ $questionIDs = array_column($questions, 'questionID'); foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $q['orderIndex'] = (int)$q['orderIndex']; + $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; + $q['questionKey'] = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID); $ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex FROM answer_option WHERE questionID = :qid ORDER BY orderIndex"); $ao->execute([':qid' => $q['questionID']]); diff --git a/website/js/pages/results.js b/website/js/pages/results.js index b09e45d..f537be8 100644 --- a/website/js/pages/results.js +++ b/website/js/pages/results.js @@ -2,7 +2,8 @@ import { apiGet } from '../api.js'; import { showToast } from '../app.js'; import { isDevTestClientCode, - questionLocalKey, + questionDisplayKey, + questionGermanLabel, testDataRowClassForClient, } from '../test-data.js'; @@ -106,7 +107,7 @@ function renderResults() { Export CSV -

Question columns show keys; hover headers for full text. Test rows (DEV-CL-*) have a yellow background.

+

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

@@ -121,7 +122,7 @@ function renderResults() { const qKeyList = document.getElementById('questionKeyList'); qKeyList.innerHTML = questionsDef - .map(q => ``) + .map(q => ``) .join(''); document.getElementById('filterSupervisor').addEventListener('change', (e) => { @@ -202,11 +203,12 @@ function renderTableHead() { { key: 'completedAt', label: 'Completed' }, ]; const questionCols = questionsDef.map(q => { - const qKey = questionLocalKey(q.questionID, q.defaultText); + const qKey = questionDisplayKey(q); + const german = questionGermanLabel(q); return { key: `q_${q.questionID}`, label: qKey, - title: q.defaultText || qKey, + title: german || qKey, questionID: q.questionID, questionKey: qKey, }; @@ -446,7 +448,7 @@ function exportCSV() { }); const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt', - ...questionsDef.map(q => questionLocalKey(q.questionID, q.defaultText))]; + ...questionsDef.map(q => questionDisplayKey(q))]; const rows = clients.map(c => { const base = [ diff --git a/website/js/test-data.js b/website/js/test-data.js index 20bd554..9e170db 100644 --- a/website/js/test-data.js +++ b/website/js/test-data.js @@ -16,7 +16,9 @@ export function testDataRowClassForUser(username) { return isDevTestUsername(username) ? ' row-test-data' : ''; } -/** Local question key from full questionID (hash__q5 → q5). */ +const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; + +/** Local question id from full questionID (hash__q5 → q5). */ export function questionLocalKey(questionID, fallback = '') { const id = String(questionID ?? ''); const sep = id.lastIndexOf('__'); @@ -24,6 +26,39 @@ export function questionLocalKey(questionID, fallback = '') { return fallback || id; } +function parseQuestionConfig(q) { + if (!q || typeof q !== 'object') return {}; + let cfg = q.configJson; + if (typeof cfg === 'string') { + try { + cfg = JSON.parse(cfg); + } catch { + cfg = {}; + } + } + return cfg && typeof cfg === 'object' ? cfg : {}; +} + +/** Stable question key for table headers / CSV (matches server qdb_question_column_label). */ +export function questionDisplayKey(q) { + if (!q) return ''; + if (q.questionKey) return String(q.questionKey); + const cfg = parseQuestionConfig(q); + const fromCfg = String(cfg.questionKey || '').trim(); + if (fromCfg && STABLE_KEY_RE.test(fromCfg)) return fromCfg; + const dt = String(q.defaultText || '').trim(); + if (dt && STABLE_KEY_RE.test(dt)) return dt; + return questionLocalKey(q.questionID, dt); +} + +/** German question text for header tooltips. */ +export function questionGermanLabel(q) { + const dt = String(q?.defaultText || '').trim(); + const key = questionDisplayKey(q); + if (dt && dt !== key) return dt; + return ''; +} + /** header_order.json column id → question key (questionnaire_x-y → y). */ export function headerColumnQuestionKey(headerId) { const id = String(headerId ?? '');