import { apiGet, apiPut } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { clientTableActionsHTML, confirmAndPatchClientArchive } from '../client-archive.js';
let overview = null;
let staleClients = [];
let followupSearch = '';
export async function insightsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Insights')}
`;
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 =
`${esc(e.message)}
`;
}
}
function renderInsights() {
const el = document.getElementById('insightsContent');
const ov = overview || {};
const kpiCards = `
${ov.clientCount ?? 0}
Clients
${ov.clientsWithCompletions ?? 0}
With completions
${ov.submissionsLast7d ?? 0}
Uploads (7 days)
${ov.submissionsLast30d ?? 0}
Uploads (30 days)
`;
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 `
${esc(sp.name)}
${sp.clientCount ?? 0} client(s) with complete profile
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
${sp.pendingReview ? ` · ${sp.pendingReview} pending Counselor review` : ''}
Calculated bands
${computedChart}
Counselor decisions
${coachChart}
`;
}).join('');
const qRows = (ov.questionnaires || []).map(q => `
| ${esc(q.name)} |
${q.completedCount ?? 0} / ${q.eligibleCount ?? 0} |
${q.completionRatio ?? 0}% |
`).join('');
el.innerHTML = `
${kpiCards}
Uploads per day
Last 14 days
${uploadChart}
Client engagement
Among clients in your scope
${engagementChart}
Days since last activity
Clients with at least one completion
${idleChart}
${scoringProfiles.length ? `
Scoring profiles
Calculated vs Counselor band distribution among clients with a complete weighted score
${scoringCharts}
` : ''}
Completion by questionnaire
Share of clients who completed each active questionnaire
${completionChart}
| Questionnaire | Completed | Rate |
${qRows || '| No active questionnaires |
'}
`;
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.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) {
const code = esc(c.clientCode);
return `
${code} |
${esc(c.coachUsername)} |
${esc(c.lastQuestionnaireName || '—')} |
${esc(c.lastCompletedAt || '—')} |
${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'} |
|
${clientTableActionsHTML({ clientCode: code, archived: false, showDelete: false })}
|
`;
}
function renderFollowupTableBody() {
const body = document.getElementById('followupTableBody');
const countEl = document.getElementById('followupListCount');
if (!body) return;
const filtered = filteredFollowupClients();
if (!filtered.length) {
body.innerHTML = `
|
${staleClients.length ? 'No clients match' : 'No clients'}
|
`;
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);
});
});
body.querySelectorAll('.archive-client-btn').forEach(btn => {
btn.addEventListener('click', () => archiveFromFollowup(btn.dataset.code));
});
}
async function archiveFromFollowup(clientCode) {
const ok = await confirmAndPatchClientArchive(clientCode, true);
if (!ok) return;
staleClients = staleClients.filter(c => c.clientCode !== clientCode);
renderFollowupTableBody();
}
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 `${esc(emptyLabel)}
`;
}
const max = chartMax(items.map(i => i.value));
return `
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
return `
${item.value}
${esc(item.label)}
`;
}).join('')}
`;
}
function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) {
if (!items.length) {
return 'No data
';
}
const max = fixedMax ?? chartMax(items.map(i => i.value));
const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn'
: variant === 'muted' ? 'insights-hchart-fill--muted'
: '';
return `
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
const display = suffix === '%' ? `${item.value}${suffix}` : String(item.value);
return `
${esc(item.label)}
${esc(display)}
`;
}).join('')}
`;
}
function idleBucketItems(clients) {
const buckets = [
{ label: '0–7 days', value: 0 },
{ label: '8–30 days', value: 0 },
{ label: '31–90 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);
}