fixed session numbering
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-01 08:44:29 +02:00
parent 9db886a1ae
commit e589b46a8a
4 changed files with 92 additions and 28 deletions

View File

@ -40,11 +40,12 @@ case 'POST':
if ($name === '') { if ($name === '') {
json_error('BAD_REQUEST', 'name is required', 400); json_error('BAD_REQUEST', 'name is required', 400);
} }
$sessionNumber = max(1, (int)($body['sessionNumber'] ?? 1));
$orderIndex = max(0, (int)($body['orderIndex'] ?? 0));
$members = qdb_parse_program_session_questionnaires($body['questionnaires'] ?? []); $members = qdb_parse_program_session_questionnaires($body['questionnaires'] ?? []);
try { try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$order = qdb_program_session_resolve_order($pdo, $body);
$sessionNumber = $order['sessionNumber'];
$orderIndex = $order['orderIndex'];
$err = qdb_validate_program_session_questionnaires($pdo, $members); $err = qdb_validate_program_session_questionnaires($pdo, $members);
if ($err !== null) { if ($err !== null) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
@ -103,6 +104,7 @@ case 'PUT':
json_error('INVALID_FIELD', $err, 400); json_error('INVALID_FIELD', $err, 400);
} }
} }
$order = qdb_program_session_resolve_order($pdo, $body, $existing);
$pdo->prepare( $pdo->prepare(
'UPDATE program_session SET 'UPDATE program_session SET
name = :name, name = :name,
@ -115,12 +117,8 @@ case 'PUT':
)->execute([ )->execute([
':name' => $name, ':name' => $name,
':desc' => trim($body['description'] ?? $existing['description']), ':desc' => trim($body['description'] ?? $existing['description']),
':num' => isset($body['sessionNumber']) ':num' => $order['sessionNumber'],
? max(1, (int)$body['sessionNumber']) ':ord' => $order['orderIndex'],
: $existing['sessionNumber'],
':ord' => isset($body['orderIndex'])
? max(0, (int)$body['orderIndex'])
: $existing['orderIndex'],
':act' => isset($body['isActive']) ':act' => isset($body['isActive'])
? (!empty($body['isActive']) ? 1 : 0) ? (!empty($body['isActive']) ? 1 : 0)
: $existing['isActive'], : $existing['isActive'],

View File

@ -109,6 +109,48 @@ function qdb_get_program_session(PDO $pdo, string $sessionID): ?array {
return $row; return $row;
} }
/**
* sessionNumber (1-based visit index) and orderIndex (0-based sort key) stay in sync:
* orderIndex = sessionNumber - 1.
*
* @param array<string, mixed> $body
* @param array<string, mixed>|null $existing
* @return array{sessionNumber: int, orderIndex: int}
*/
function qdb_program_session_resolve_order(PDO $pdo, array $body, ?array $existing = null): array
{
if (isset($body['sessionNumber'])) {
$sessionNumber = max(1, (int)$body['sessionNumber']);
return [
'sessionNumber' => $sessionNumber,
'orderIndex' => $sessionNumber - 1,
];
}
if (isset($body['orderIndex'])) {
$orderIndex = max(0, (int)$body['orderIndex']);
return [
'sessionNumber' => $orderIndex + 1,
'orderIndex' => $orderIndex,
];
}
if ($existing !== null) {
$sessionNumber = max(1, (int)$existing['sessionNumber']);
return [
'sessionNumber' => $sessionNumber,
'orderIndex' => $sessionNumber - 1,
];
}
$maxNum = (int)$pdo->query('SELECT COALESCE(MAX(sessionNumber), 0) FROM program_session')->fetchColumn();
$maxOrd = (int)$pdo->query('SELECT COALESCE(MAX(orderIndex), -1) FROM program_session')->fetchColumn();
$sessionNumber = max($maxNum, $maxOrd + 1, 0) + 1;
return [
'sessionNumber' => $sessionNumber,
'orderIndex' => $sessionNumber - 1,
];
}
/** /**
* @return list<array{questionnaireID: string, orderIndex: int}> * @return list<array{questionnaireID: string, orderIndex: int}>
*/ */

View File

@ -34,6 +34,36 @@ final class ProgramSessionsTest extends TestCase
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
} }
public function testResolveOrderFromSessionNumber(): void
{
[$pdo] = qdb_open(true);
require_once dirname(__DIR__, 2) . '/lib/program_sessions.php';
$order = qdb_program_session_resolve_order($pdo, ['sessionNumber' => 3]);
$this->assertSame(3, $order['sessionNumber']);
$this->assertSame(2, $order['orderIndex']);
}
public function testResolveOrderFromOrderIndexLegacy(): void
{
[$pdo] = qdb_open(true);
require_once dirname(__DIR__, 2) . '/lib/program_sessions.php';
$order = qdb_program_session_resolve_order($pdo, ['orderIndex' => 4]);
$this->assertSame(5, $order['sessionNumber']);
$this->assertSame(4, $order['orderIndex']);
}
public function testResolveOrderDefaultsToNextSession(): void
{
[$pdo] = qdb_open(true);
require_once dirname(__DIR__, 2) . '/lib/program_sessions.php';
$order = qdb_program_session_resolve_order($pdo, []);
$this->assertSame(2, $order['sessionNumber']);
$this->assertSame(1, $order['orderIndex']);
}
public function testAdvanceCycleWhenSessionComplete(): void public function testAdvanceCycleWhenSessionComplete(): void
{ {
[$pdo, $tmpDb, $lockFp] = qdb_open(true); [$pdo, $tmpDb, $lockFp] = qdb_open(true);

View File

@ -36,8 +36,7 @@ export async function sessionsPage() {
function renderSessions(sessions, questionnaires, settings) { function renderSessions(sessions, questionnaires, settings) {
const el = document.getElementById('sessionsContent'); const el = document.getElementById('sessionsContent');
const sorted = [...sessions].sort((a, b) => const sorted = [...sessions].sort((a, b) =>
(a.orderIndex ?? 0) - (b.orderIndex ?? 0) effectiveSessionNumber(a) - effectiveSessionNumber(b)
|| (a.sessionNumber ?? 0) - (b.sessionNumber ?? 0)
|| String(a.name).localeCompare(String(b.name)), || String(a.name).localeCompare(String(b.name)),
); );
const autoRepeat = !!settings.autoRepeatSessions; const autoRepeat = !!settings.autoRepeatSessions;
@ -145,7 +144,7 @@ function sessionCardHTML(session, questionnaires) {
<strong>${esc(session.name)}</strong> <strong>${esc(session.name)}</strong>
${activeBadge} ${activeBadge}
</div> </div>
<p class="scoring-profile-bands">Session ${session.sessionNumber} · Order ${session.orderIndex}</p> <p class="scoring-profile-bands">Session ${effectiveSessionNumber(session)}</p>
${session.description ${session.description
? `<p class="scoring-profile-desc">${esc(session.description)}</p>` ? `<p class="scoring-profile-desc">${esc(session.description)}</p>`
: ''} : ''}
@ -174,9 +173,7 @@ function openSessionModal(session, questionnaires, allSessions) {
})) }))
: []; : [];
const selectedIds = new Set(members.map(m => m.questionnaireID)); const selectedIds = new Set(members.map(m => m.questionnaireID));
const defaultOrder = allSessions?.length const defaultSessionNumber = (allSessions?.length ?? 0) + 1;
? Math.max(...allSessions.map(s => s.orderIndex ?? 0)) + 1
: 0;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
@ -191,21 +188,15 @@ function openSessionModal(session, questionnaires, allSessions) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sessNumber">Session number</label> <label for="sessNumber">Session number</label>
<input type="number" id="sessNumber" min="1" value="${session?.sessionNumber ?? 1}"> <input type="number" id="sessNumber" min="1" value="${session ? effectiveSessionNumber(session) : defaultSessionNumber}">
</div> </div>
</div> </div>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div class="form-group scoring-profile-active-row">
<div class="form-group">
<label for="sessOrder">Display order</label>
<input type="number" id="sessOrder" min="0" value="${session?.orderIndex ?? defaultOrder}">
</div>
<div class="form-group scoring-profile-active-row" style="align-self:end;padding-bottom:8px">
<label class="checkbox-inline"> <label class="checkbox-inline">
<input type="checkbox" id="sessActive" ${session?.isActive !== 0 ? 'checked' : ''}> <input type="checkbox" id="sessActive" ${session?.isActive !== 0 ? 'checked' : ''}>
Active (visible in app) Active (visible in app)
</label> </label>
</div> </div>
</div>
<div class="form-group"> <div class="form-group">
<label for="sessDesc">Description</label> <label for="sessDesc">Description</label>
<textarea id="sessDesc" rows="2" placeholder="Optional note for counselors">${esc(session?.description || '')}</textarea> <textarea id="sessDesc" rows="2" placeholder="Optional note for counselors">${esc(session?.description || '')}</textarea>
@ -314,7 +305,6 @@ function openSessionModal(session, questionnaires, allSessions) {
const name = overlay.querySelector('#sessName')?.value.trim(); const name = overlay.querySelector('#sessName')?.value.trim();
const description = overlay.querySelector('#sessDesc')?.value.trim() || ''; const description = overlay.querySelector('#sessDesc')?.value.trim() || '';
const sessionNumber = parseInt(overlay.querySelector('#sessNumber')?.value || '1', 10); const sessionNumber = parseInt(overlay.querySelector('#sessNumber')?.value || '1', 10);
const orderIndex = parseInt(overlay.querySelector('#sessOrder')?.value || '0', 10);
const isActive = overlay.querySelector('#sessActive')?.checked; const isActive = overlay.querySelector('#sessActive')?.checked;
if (!name) { if (!name) {
showToast('Session name is required', 'error'); showToast('Session name is required', 'error');
@ -324,7 +314,6 @@ function openSessionModal(session, questionnaires, allSessions) {
name, name,
description, description,
sessionNumber: Number.isFinite(sessionNumber) ? Math.max(1, sessionNumber) : 1, sessionNumber: Number.isFinite(sessionNumber) ? Math.max(1, sessionNumber) : 1,
orderIndex: Number.isFinite(orderIndex) ? Math.max(0, orderIndex) : 0,
isActive, isActive,
questionnaires: members.map((m, idx) => ({ questionnaires: members.map((m, idx) => ({
questionnaireID: m.questionnaireID, questionnaireID: m.questionnaireID,
@ -347,6 +336,11 @@ function openSessionModal(session, questionnaires, allSessions) {
}); });
} }
function effectiveSessionNumber(session) {
if (!session) return 1;
return Math.max(session.sessionNumber ?? 1, (session.orderIndex ?? 0) + 1);
}
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s ?? ''; d.textContent = s ?? '';