Files
nat-as-server/website/js/pages/insights.js
Tom Hempel d807f753b1
Some checks failed
PHPUnit / test (push) Has been cancelled
switched first completed to last completed on needs followup table
2026-06-12 20:20:56 +02:00

361 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { apiGet, apiPut } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
let overview = null;
let staleClients = [];
let followupSearch = '';
export async function insightsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Insights')}
<div id="insightsContent"><div class="spinner"></div></div>
`;
try {
const [ov, stale] = await Promise.all([
apiGet('analytics.php?overview=1'),
apiGet('analytics.php?staleClients=1'),
]);
overview = ov;
staleClients = stale.clients || [];
renderInsights();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('insightsContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderInsights() {
const el = document.getElementById('insightsContent');
const ov = overview || {};
const kpiCards = `
<div class="insights-kpi-grid">
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.clientCount ?? 0}</div>
<div class="insights-kpi-label">Clients</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.clientsWithCompletions ?? 0}</div>
<div class="insights-kpi-label">With completions</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.submissionsLast7d ?? 0}</div>
<div class="insights-kpi-label">Uploads (7 days)</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
<div class="insights-kpi-label">Uploads (30 days)</div>
</div>
</div>
`;
const uploadChart = verticalBarChart(
(ov.submissionsByDay || []).map(d => ({
label: formatChartDay(d.date),
value: d.count,
title: `${d.date}: ${d.count} upload(s)`,
})),
{ emptyLabel: 'No uploads in this period' }
);
const withoutCompletions = Math.max(0, (ov.clientCount ?? 0) - (ov.clientsWithCompletions ?? 0));
const engagementChart = horizontalBarChart([
{ label: 'With completions', value: ov.clientsWithCompletions ?? 0 },
{ label: 'No completions yet', value: withoutCompletions },
], { suffix: '' });
const idleChart = horizontalBarChart(idleBucketItems(staleClients), {
suffix: '',
variant: 'warn',
});
const completionChart = horizontalBarChart(
(ov.questionnaires || []).map(q => ({
label: q.name,
value: q.completionRatio ?? 0,
title: `${q.name}: ${q.completionRatio ?? 0}%`,
})),
{ suffix: '%', max: 100 }
);
const scoringProfiles = ov.scoringProfiles || [];
const scoringCharts = scoringProfiles.map(sp => {
const computed = sp.computedBands || sp.bands || {};
const coach = sp.coachBands || {};
const computedChart = horizontalBarChart([
{ label: 'Green', value: computed.green ?? 0 },
{ label: 'Yellow', value: computed.yellow ?? 0 },
{ label: 'Red', value: computed.red ?? 0 },
], { suffix: '' });
const coachChart = horizontalBarChart([
{ label: 'Green', value: coach.green ?? 0 },
{ label: 'Yellow', value: coach.yellow ?? 0 },
{ label: 'Red', value: coach.red ?? 0 },
], { suffix: '' });
return `
<div class="card insights-chart-card">
<h3>${esc(sp.name)}</h3>
<p class="insights-chart-hint">
${sp.clientCount ?? 0} client(s) with complete profile
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
${sp.pendingReview ? ` · ${sp.pendingReview} pending counselor review` : ''}
</p>
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
${computedChart}
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Counselor decisions</strong></p>
${coachChart}
</div>`;
}).join('');
const qRows = (ov.questionnaires || []).map(q => `
<tr>
<td><strong>${esc(q.name)}</strong></td>
<td>${q.completedCount ?? 0} / ${q.eligibleCount ?? 0}</td>
<td>${q.completionRatio ?? 0}%</td>
</tr>
`).join('');
el.innerHTML = `
${kpiCards}
<div class="insights-charts-grid">
<div class="card insights-chart-card">
<h3>Uploads per day</h3>
<p class="insights-chart-hint">Last 14 days</p>
${uploadChart}
</div>
<div class="card insights-chart-card">
<h3>Client engagement</h3>
<p class="insights-chart-hint">Among clients in your scope</p>
${engagementChart}
</div>
<div class="card insights-chart-card">
<h3>Days since last activity</h3>
<p class="insights-chart-hint">Clients with at least one completion</p>
${idleChart}
</div>
</div>
${scoringProfiles.length ? `
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Scoring profiles</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs counselor band distribution among clients with a complete weighted score</p>
<div class="insights-charts-grid">${scoringCharts}</div>
</div>` : ''}
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
${completionChart}
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr><th>Questionnaire</th><th>Completed</th><th>Rate</th></tr>
</thead>
<tbody>${qRows || '<tr><td colspan="3">No active questionnaires</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Needs follow-up</h3>
<p class="data-toolbar-hint" style="margin:0 0 12px">
Clients who completed at least one questionnaire, sorted by longest time since any upload activity.
</p>
<div class="filter-bar">
<input type="search" id="followupListSearch" class="filter-search"
placeholder="Search client, counselor, questionnaire, note…" value="${esc(followupSearch)}">
<span class="data-toolbar-hint" id="followupListCount"></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Client</th><th>Counselor</th><th>Last completed</th><th>Last at</th>
<th>Last activity</th><th>Days idle</th><th>Note</th>
</tr>
</thead>
<tbody id="followupTableBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('followupListSearch').addEventListener('input', (e) => {
followupSearch = e.target.value;
renderFollowupTableBody();
});
renderFollowupTableBody();
}
function followupSearchText(c) {
return [
c.clientCode,
c.coachUsername,
c.lastQuestionnaireName,
c.lastQuestionnaireID,
c.note,
c.lastCompletedAt,
c.lastActivityAt,
c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '',
].filter(Boolean).join(' ');
}
function filteredFollowupClients() {
const q = followupSearch.trim().toLowerCase();
if (!q) return staleClients;
return staleClients.filter(c => followupSearchText(c).toLowerCase().includes(q));
}
function followupRowHTML(c) {
return `
<tr data-client="${esc(c.clientCode)}">
<td><code>${esc(c.clientCode)}</code></td>
<td>${esc(c.coachUsername)}</td>
<td>${esc(c.lastQuestionnaireName || '—')}</td>
<td>${esc(c.lastCompletedAt || '—')}</td>
<td>${esc(c.lastActivityAt || '—')}</td>
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
<td>
<textarea class="followup-note-input" rows="2" data-client="${esc(c.clientCode)}"
placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
<button type="button" class="btn btn-sm save-note-btn" data-client="${esc(c.clientCode)}">Save</button>
</td>
</tr>`;
}
function renderFollowupTableBody() {
const body = document.getElementById('followupTableBody');
const countEl = document.getElementById('followupListCount');
if (!body) return;
const filtered = filteredFollowupClients();
if (!filtered.length) {
body.innerHTML = `
<tr>
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
${staleClients.length ? 'No clients match' : 'No clients'}
</td>
</tr>`;
if (countEl) {
countEl.textContent = followupSearch.trim() && staleClients.length
? `0 of ${staleClients.length} clients`
: '';
}
return;
}
body.innerHTML = filtered.map(c => followupRowHTML(c)).join('');
if (countEl) {
const q = followupSearch.trim();
countEl.textContent = q
? `${filtered.length} of ${staleClients.length} clients`
: `${staleClients.length} client${staleClients.length === 1 ? '' : 's'}`;
}
body.querySelectorAll('.save-note-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tr = btn.closest('tr');
const ta = tr?.querySelector('.followup-note-input');
saveNote(btn.dataset.client, ta);
});
});
}
async function saveNote(clientCode, ta) {
if (!ta) return;
try {
await apiPut('analytics.php', { clientCode, note: ta.value.trim() });
const row = staleClients.find(c => c.clientCode === clientCode);
if (row) row.note = ta.value.trim();
showToast('Note saved', 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function formatChartDay(isoDate) {
if (!isoDate) return '';
const d = new Date(isoDate + 'T12:00:00');
if (Number.isNaN(d.getTime())) return isoDate.slice(5);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function chartMax(values, fallbackMax = 1) {
const m = Math.max(0, ...values);
return m > 0 ? m : fallbackMax;
}
function verticalBarChart(items, { emptyLabel = 'No data' } = {}) {
if (!items.length) {
return `<p class="insights-chart-hint">${esc(emptyLabel)}</p>`;
}
const max = chartMax(items.map(i => i.value));
return `
<div class="insights-vchart" role="img" aria-label="Bar chart">
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
return `
<div class="insights-vchart-col" title="${esc(item.title || `${item.label}: ${item.value}`)}">
<div class="insights-vchart-bar-wrap">
<div class="insights-vchart-bar" style="height:${pct}%"></div>
</div>
<span class="insights-vchart-value">${item.value}</span>
<span class="insights-vchart-label">${esc(item.label)}</span>
</div>`;
}).join('')}
</div>`;
}
function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) {
if (!items.length) {
return '<p class="insights-chart-hint">No data</p>';
}
const max = fixedMax ?? chartMax(items.map(i => i.value));
const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn'
: variant === 'muted' ? 'insights-hchart-fill--muted'
: '';
return `
<div class="insights-hchart" role="img" aria-label="Bar chart">
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
const display = suffix === '%' ? `${item.value}${suffix}` : String(item.value);
return `
<div class="insights-hchart-row" title="${esc(item.title || `${item.label}: ${display}`)}">
<span class="insights-hchart-label">${esc(item.label)}</span>
<div class="insights-hchart-track">
<div class="insights-hchart-fill ${fillClass}" style="width:${pct}%"></div>
</div>
<span class="insights-hchart-num">${esc(display)}</span>
</div>`;
}).join('')}
</div>`;
}
function idleBucketItems(clients) {
const buckets = [
{ label: '07 days', value: 0 },
{ label: '830 days', value: 0 },
{ label: '3190 days', value: 0 },
{ label: '90+ days', value: 0 },
{ label: 'No uploads yet', value: 0 },
];
for (const c of clients) {
const d = c.daysSinceLastActivity;
if (d == null) buckets[4].value += 1;
else if (d <= 7) buckets[0].value += 1;
else if (d <= 30) buckets[1].value += 1;
else if (d <= 90) buckets[2].value += 1;
else buckets[3].value += 1;
}
return buckets.filter(b => b.value > 0);
}