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]);
$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);
}

View File

@ -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<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. */
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]);
$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);
}

View File

@ -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() {
<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>
</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 class="card">
<div class="table-wrapper" id="resultsTableWrap">
@ -121,8 +123,8 @@ function renderResults() {
`;
const qKeyList = document.getElementById('questionKeyList');
qKeyList.innerHTML = questionsDef
.map(q => `<option value="${esc(questionDisplayKey(q))}"></option>`)
qKeyList.innerHTML = resultColumns
.map(col => `<option value="${esc(col.label)}"></option>`)
.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 ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—';
const questionCells = questionsDef.map(q => {
const ans = c.answers && c.answers[q.questionID];
if (!ans) return '<td>—</td>';
if (q.type === 'glass_scale_question') {
const text = formatGlassAnswer(ans.freeTextValue);
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>';
const questionCells = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID];
const val = resultColumnCellValue(col, ans, optionMap);
if (val == null || val === '') return '<td>—</td>';
return `<td>${esc(String(val))}</td>`;
}).join('');
return `<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
@ -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 ?? '';

View File

@ -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 ?? '');