UX improvements in data views

This commit is contained in:
2026-05-27 09:58:08 +02:00
parent 25851c4b86
commit ff370d0d39
5 changed files with 185 additions and 91 deletions

View File

@ -58,12 +58,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config
$qStmt->execute([':id' => $qnID]); $qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID'); $questionIDs = array_column($questions, 'questionID');
$questionColumnLabels = []; $resultColumns = qdb_results_export_columns($questions, $qnID);
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 // Build answer-option lookup for resolving display text
$optionTextMap = []; $optionTextMap = [];
@ -121,10 +116,10 @@ foreach ($clients as $c) {
$answerMap = []; $answerMap = [];
foreach ($answers as $a) $answerMap[$a['questionID']] = $a; foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
foreach ($questions as $q) { foreach ($resultColumns as $col) {
$qid = $q['questionID']; $qid = $col['questionID'];
$a = $answerMap[$qid] ?? null; $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; $rows[] = $row;
} }
@ -149,8 +144,8 @@ if (!empty($rows)) {
} }
} else { } else {
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) { foreach ($resultColumns as $col) {
$header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; $header[] = $col['header'];
} }
fputcsv($out, $header); fputcsv($out, $header);
} }

View File

@ -532,6 +532,73 @@ function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabel
return json_encode($data, JSON_UNESCAPED_UNICODE); 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<array{header: string, questionID: string, symptomKey: ?string, kind: string, question: array}>
*/
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. */ /** Format one client_answer row for results table / CSV export. */
function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string
{ {

View File

@ -38,13 +38,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config
$qStmt->execute([':id' => $qnID]); $qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID'); $questionIDs = array_column($questions, 'questionID');
$questionColumnLabels = []; $resultColumns = qdb_results_export_columns($questions, $qnID);
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 = []; $optionTextMap = [];
foreach ($questionIDs as $qid) { foreach ($questionIDs as $qid) {
@ -99,10 +93,10 @@ foreach ($clients as $c) {
$answerMap = []; $answerMap = [];
foreach ($answers as $a) $answerMap[$a['questionID']] = $a; foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
foreach ($questions as $q) { foreach ($resultColumns as $col) {
$qid = $q['questionID']; $qid = $col['questionID'];
$a = $answerMap[$qid] ?? null; $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; $rows[] = $row;
} }
@ -126,8 +120,8 @@ if (!empty($rows)) {
} }
} else { } else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt']; $header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) { foreach ($resultColumns as $col) {
$header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText']; $header[] = $col['header'];
} }
fputcsv($out, $header); fputcsv($out, $header);
} }

View File

@ -1,9 +1,9 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { showToast } from '../app.js'; import { showToast } from '../app.js';
import { import {
buildResultColumns,
isDevTestClientCode, isDevTestClientCode,
questionDisplayKey, resultColumnCellValue,
questionGermanLabel,
testDataRowClassForClient, testDataRowClassForClient,
} from '../test-data.js'; } from '../test-data.js';
@ -13,6 +13,7 @@ let sortCol = null;
let sortDir = 'asc'; let sortDir = 'asc';
let allClients = []; let allClients = [];
let questionsDef = []; let questionsDef = [];
let resultColumns = [];
let questionnaireMeta = null; let questionnaireMeta = null;
let filterCoach = ''; let filterCoach = '';
let filterSupervisor = ''; let filterSupervisor = '';
@ -64,6 +65,7 @@ export async function resultsPage(params) {
function renderResults() { function renderResults() {
const container = document.getElementById('resultsContent'); const container = document.getElementById('resultsContent');
resultColumns = buildResultColumns(questionsDef);
// Collect unique coaches and statuses for filters // Collect unique coaches and statuses for filters
const coachOptions = getCoachFilterOptions(); const coachOptions = getCoachFilterOptions();
@ -107,7 +109,7 @@ function renderResults() {
<span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span> <span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span>
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a> <a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
</div> </div>
<p class="data-toolbar-hint" style="margin-top:8px">Question columns show stable keys (e.g. country_of_origin); hover for German text. Test rows (DEV-CL-*) have a yellow background.</p> <p class="data-toolbar-hint" style="margin-top:8px">Each question has its own column; glass-scale symptoms are separate columns. Hover headers for context. Test rows (DEV-CL-*) are highlighted.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrapper" id="resultsTableWrap"> <div class="table-wrapper" id="resultsTableWrap">
@ -121,8 +123,8 @@ function renderResults() {
`; `;
const qKeyList = document.getElementById('questionKeyList'); const qKeyList = document.getElementById('questionKeyList');
qKeyList.innerHTML = questionsDef qKeyList.innerHTML = resultColumns
.map(q => `<option value="${esc(questionDisplayKey(q))}"></option>`) .map(col => `<option value="${esc(col.label)}"></option>`)
.join(''); .join('');
document.getElementById('filterSupervisor').addEventListener('change', (e) => { document.getElementById('filterSupervisor').addEventListener('change', (e) => {
@ -202,18 +204,11 @@ function renderTableHead() {
{ key: 'sumPoints', label: 'Score' }, { key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' }, { key: 'completedAt', label: 'Completed' },
]; ];
const questionCols = questionsDef.map(q => { const allCols = [...fixedCols, ...resultColumns.map(col => ({
const qKey = questionDisplayKey(q); key: col.key,
const german = questionGermanLabel(q); label: col.label,
return { title: col.title,
key: `q_${q.questionID}`, }))];
label: qKey,
title: german || qKey,
questionID: q.questionID,
questionKey: qKey,
};
});
const allCols = [...fixedCols, ...questionCols];
head.innerHTML = allCols.map((col, idx) => { head.innerHTML = allCols.map((col, idx) => {
let cls = 'sortable'; let cls = 'sortable';
@ -389,19 +384,11 @@ function renderTableRows() {
const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—'; const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—';
const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—'; const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—';
const questionCells = questionsDef.map(q => { const questionCells = resultColumns.map(col => {
const ans = c.answers && c.answers[q.questionID]; const ans = c.answers && c.answers[col.questionID];
if (!ans) return '<td>—</td>'; const val = resultColumnCellValue(col, ans, optionMap);
if (q.type === 'glass_scale_question') { if (val == null || val === '') return '<td>—</td>';
const text = formatGlassAnswer(ans.freeTextValue); return `<td>${esc(String(val))}</td>`;
return `<td>${esc(text || '—')}</td>`;
}
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
}
if (ans.freeTextValue) return `<td>${esc(ans.freeTextValue)}</td>`;
if (ans.numericValue !== null && ans.numericValue !== undefined) return `<td>${ans.numericValue}</td>`;
return '<td>—</td>';
}).join(''); }).join('');
return `<tr class="${testDataRowClassForClient(c.clientCode).trim()}"> return `<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
@ -423,17 +410,16 @@ function getCellValue(client, key) {
if (key === 'status') return client.status; if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints; if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt; if (key === 'completedAt') return client.completedAt;
if (key.startsWith('q_')) { const col = resultColumns.find(c => c.key === key);
const qid = key.substring(2); if (col) {
const q = questionsDef.find(qq => qq.questionID === qid); const ans = client.answers && client.answers[col.questionID];
const ans = client.answers && client.answers[qid]; const optionMap = {};
if (!ans) return null; questionsDef.forEach(q => {
if (q?.type === 'glass_scale_question') { (q.answerOptions || []).forEach(o => {
return formatGlassAnswer(ans.freeTextValue) || null; optionMap[o.answerOptionID] = o.defaultText;
} });
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue; });
if (ans.freeTextValue) return ans.freeTextValue; return resultColumnCellValue(col, ans, optionMap);
return ans.answerOptionID || null;
} }
return null; return null;
} }
@ -448,23 +434,17 @@ function exportCSV() {
}); });
const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt', const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt',
...questionsDef.map(q => questionDisplayKey(q))]; ...resultColumns.map(col => col.label)];
const rows = clients.map(c => { const rows = clients.map(c => {
const base = [ const base = [
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '', c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '',
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : '' c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
]; ];
const answerCols = questionsDef.map(q => { const answerCols = resultColumns.map(col => {
const ans = c.answers && c.answers[q.questionID]; const ans = c.answers && c.answers[col.questionID];
if (!ans) return ''; const val = resultColumnCellValue(col, ans, optionMap);
if (q.type === 'glass_scale_question') { return val != null ? val : '';
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 '';
}); });
return [...base, ...answerCols]; return [...base, ...answerCols];
}); });
@ -491,22 +471,6 @@ function csvEsc(val) {
return s; 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) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s ?? ''; d.textContent = s ?? '';

View File

@ -59,6 +59,80 @@ export function questionGermanLabel(q) {
return ''; 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). */ /** header_order.json column id → question key (questionnaire_x-y → y). */
export function headerColumnQuestionKey(headerId) { export function headerColumnQuestionKey(headerId) {
const id = String(headerId ?? ''); const id = String(headerId ?? '');