diff --git a/backend/src/jobs/cohortExtension.js b/backend/src/jobs/cohortExtension.js new file mode 100644 index 0000000..2075048 --- /dev/null +++ b/backend/src/jobs/cohortExtension.js @@ -0,0 +1,28 @@ +import cron from 'node-cron'; +import { env } from '../config/env.js'; +import { appendTodaySessions } from '../services/programSessions.js'; +import { todayYmd } from '../services/schedule.js'; + +// Adds today's session to open cohorts whose planned sessions are used up. +export async function runCohortExtension(dateYmd) { + const date = dateYmd || todayYmd(env.APP_TIMEZONE); + const added = await appendTodaySessions(date); + console.log(`[cohort-extension] ${date}: added=${added}`); + return added; +} + +export function startCohortExtensionJobs() { + cron.schedule('30 5 * * *', async () => { + try { + await runCohortExtension(); + } catch (err) { + console.error('[cohort-extension] cron failed', err); + } + }, { + timezone: env.APP_TIMEZONE, + name: 'cohort-extension', + noOverlap: true, + }); + console.log(`[cohort-extension] scheduled daily 05:30 in ${env.APP_TIMEZONE}`); + runCohortExtension().catch((err) => console.error('[cohort-extension] startup run failed', err)); +} diff --git a/backend/src/models/OnboardedPatient.js b/backend/src/models/OnboardedPatient.js index e392cd0..c4c943a 100644 --- a/backend/src/models/OnboardedPatient.js +++ b/backend/src/models/OnboardedPatient.js @@ -34,6 +34,7 @@ const onboardedPatientSchema = new mongoose.Schema( onboardedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, assignedEducator: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, cohort: { type: mongoose.Schema.Types.ObjectId, ref: 'Cohort', default: null }, + cohortJoinedOn: { type: String, default: '' }, // date the patient was added to the current cohort reviewStatus: { type: String, enum: ['pending', 'confirmed', 'rejected'], diff --git a/backend/src/routes/onboarding.js b/backend/src/routes/onboarding.js index 314a976..92cfcf5 100644 --- a/backend/src/routes/onboarding.js +++ b/backend/src/routes/onboarding.js @@ -17,7 +17,7 @@ import { migratePatient } from '../services/matching.js'; import { notifyPaymentReview } from '../services/notify.js'; import { attendanceProgress, ensureSessionRecords, patientProgramStats } from '../services/programSessions.js'; import { assertConfiguredSlot, getAppSettings, slotLabel } from '../services/settings.js'; -import { toYmd, weekdayOf, ymdToDate } from '../services/schedule.js'; +import { toYmd, weekdayOf } from '../services/schedule.js'; const objectId = Joi.string().hex().length(24); function textField(payload, key) { @@ -208,11 +208,10 @@ function canReview(request) { } async function findAssignableCohort(cohortId, currentCohortId) { - const today = ymdToDate(toYmd(new Date())); const cohort = await Cohort.findById(cohortId); - if (!cohort) throw Boom.badRequest('Select a future cohort.'); + if (!cohort) throw Boom.badRequest('Select an ongoing or upcoming cohort.'); const isCurrent = currentCohortId && String(cohort._id) === String(currentCohortId); - if (!isCurrent && cohort.startDate < today) throw Boom.badRequest('Select a future cohort.'); + if (!isCurrent && cohort.status === 'completed') throw Boom.badRequest('Select an ongoing or upcoming cohort.'); return cohort; } @@ -287,7 +286,7 @@ export function onboardingRoutes(server) { return { items: items.map((item) => { const progress = stats.get(String(item._id)) || { attended: 0, total: 0 }; - return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total } }; + return { ...toOnboardedPatientDto(item), progress: { attended: progress.attended, total: progress.total, firstDate: progress.firstDate || '' } }; }), total, page: Number(page), @@ -307,10 +306,9 @@ export function onboardingRoutes(server) { path: '/api/onboarding/available-cohorts', options: { pre: [{ method: requirePermission('onboarding', 'view') }] }, handler: async (request) => { - const today = ymdToDate(toYmd(new Date())); const language = String(request.query.language || '').trim(); const include = String(request.query.include || '').trim(); - const filter = { startDate: { $gte: today }, status: { $nin: ['proposed', 'completed'] } }; + const filter = { status: { $nin: ['proposed', 'completed'] } }; if (language) filter.language = language; const items = await Cohort.find(filter) .populate('virtualPrt', 'firstName lastName email') diff --git a/backend/src/routes/programSessions.js b/backend/src/routes/programSessions.js index 035babc..e07a170 100644 --- a/backend/src/routes/programSessions.js +++ b/backend/src/routes/programSessions.js @@ -7,6 +7,7 @@ import { toCohortSessionDto } from '../utils/serialize.js'; import { generateMeetsForDate, listSessions, + patientSessionNumbers, pushSession, saveSessionRecords, } from '../services/programSessions.js'; @@ -104,7 +105,10 @@ export function programSessionRoutes(server) { pre: [{ method: requirePermission('program_sessions', 'view') }], validate: { params: Joi.object({ id: objectId.required() }) }, }, - handler: async (request) => toCohortSessionDto(await findScopedSession(request)), + handler: async (request) => { + const session = await findScopedSession(request); + return toCohortSessionDto(session, await patientSessionNumbers([session])); + }, }); server.route({ @@ -194,7 +198,7 @@ export function programSessionRoutes(server) { const saved = await saveSessionRecords(session, request.payload.records, { completed: request.payload.completed }); await saved.populate('records.patient', 'name mobile'); await saved.populate({ path: 'cohort', populate: { path: 'virtualPrt', select: 'firstName lastName email' } }); - return toCohortSessionDto(saved); + return toCohortSessionDto(saved, await patientSessionNumbers([saved])); }, }); diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 2d0d9ee..5698765 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -35,6 +35,14 @@ function sessionMark(session, patientId) { return record?.attended ? 'P' : 'A'; } +function sessionRecord(session, patientId) { + return (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId); +} + +function vitalValue(value) { + return value == null || value === '' ? '' : value; +} + export function reportRoutes(server) { server.route({ method: 'GET', @@ -60,7 +68,7 @@ export function reportRoutes(server) { const stats = await patientProgramStats(patients.map((item) => item._id)); return { items: patients.map((patient) => { - const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, vitals: [] }; + const progress = stats.get(String(patient._id)) || { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] }; const pii = readOnboardedPii(patient); return { id: String(patient._id), @@ -73,6 +81,9 @@ export function reportRoutes(server) { prt: patient.assignedEducator ? { id: String(patient.assignedEducator._id), firstName: patient.assignedEducator.firstName, lastName: patient.assignedEducator.lastName } : null, + firstSession: progress.firstDate || '', + completed: progress.completed || 0, + assigned: progress.assigned || 0, attended: progress.attended, total: progress.total, completion: progress.total ? `${progress.attended}/${progress.total}` : '0/0', @@ -199,6 +210,69 @@ export function reportRoutes(server) { items.push([`${prt.firstName} ${prt.lastName}`, used, max, Math.max(0, max - used), run, cancelledOwn]); } rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items]; + } else if (type === 'vitals') { + const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); + const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))]; + const sessions = cohortIds.length + ? await CohortSession.find({ cohort: { $in: cohortIds }, status: 'completed' }) + .select('cohort index date records.patient records.attended records.pre records.post') + .sort({ date: 1, index: 1 }) + : []; + const byCohort = new Map(); + for (const session of sessions) { + const id = String(session.cohort); + const list = byCohort.get(id) || []; + list.push(session); + byCohort.set(id, list); + } + const included = patients.filter((patient) => { + const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; + const meetings = cohortId ? (byCohort.get(cohortId) || []) : []; + return meetings.some((session) => sessionRecord(session, String(patient._id))); + }); + const groups = included.map((patient) => { + const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; + const meetings = (cohortId ? (byCohort.get(cohortId) || []) : []) + .filter((session) => sessionRecord(session, String(patient._id))); + return { patient, meetings }; + }); + const blank = ['', '', '', '', '', '']; + const lines = []; + for (const { patient, meetings } of groups) { + const pii = readOnboardedPii(patient); + const identity = [ + pii.name, + pii.mobile, + patient.diseaseType || '', + patient.cohort?.name || '', + patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '', + ]; + for (const session of meetings) { + const record = sessionRecord(session, String(patient._id)); + const pre = record?.pre || {}; + const post = record?.post || {}; + lines.push({ + date: session.date, + name: pii.name || '', + row: [ + ...identity, + `${session.date} (${sessionMark(session, String(patient._id))})`, + vitalValue(pre.bp), + vitalValue(pre.spo2), + vitalValue(pre.pulse), + vitalValue(post.bp), + vitalValue(post.spo2), + vitalValue(post.pulse), + ], + }); + } + } + lines.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name)); + rows = [ + ['Name', 'Mobile', 'Disease', 'Cohort', 'Start date', 'Session date', 'Pre', '', '', 'Post', '', ''], + [...blank, 'BP', 'SpO2', 'Pulse', 'BP', 'SpO2', 'Pulse'], + ...lines.map((line) => line.row), + ]; } else { const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))]; @@ -214,12 +288,15 @@ export function reportRoutes(server) { list.push(session); byCohort.set(id, list); } + const stats = await patientProgramStats(patients.map((item) => item._id)); const widest = [...byCohort.values()].reduce((max, list) => Math.max(max, list.length), 0); - const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)]; + const headers = ['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Start date', 'First session', 'Completed', 'Assigned', ...Array.from({ length: widest }, (_, index) => `Session ${index + 1}`)]; rows = [headers, ...patients.map((patient) => { const pii = readOnboardedPii(patient); + const progress = stats.get(String(patient._id)) || {}; const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; - const meetings = cohortId ? (byCohort.get(cohortId) || []) : []; + const meetings = (cohortId ? (byCohort.get(cohortId) || []) : []) + .filter((session) => sessionRecord(session, String(patient._id))); const marks = meetings.map((session) => sessionMark(session, String(patient._id))); while (marks.length < widest) marks.push(''); // shorter cohorts leave the extra session cells blank return [ @@ -229,6 +306,9 @@ export function reportRoutes(server) { patient.cohort?.name || '', patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '', patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '', + progress.firstDate || '', + progress.completed || 0, + progress.assigned || 0, ...marks, ]; })]; diff --git a/backend/src/server.js b/backend/src/server.js index e7dc9a7..45a8eb0 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -21,6 +21,7 @@ import { matchingRoutes } from './routes/matching.js'; import { programSessionRoutes } from './routes/programSessions.js'; import { reportRoutes } from './routes/reports.js'; import { startMeetingJobs } from './jobs/meetings.js'; +import { startCohortExtensionJobs } from './jobs/cohortExtension.js'; import { startWhatsappWorker } from './services/whatsappQueue.js'; import { startMatchingJobs } from './jobs/matching.js'; import { startRetentionJobs } from './jobs/retention.js'; @@ -107,6 +108,7 @@ export async function createServer() { auditRoutes(server); whatsappRoutes(server); startMeetingJobs(); + startCohortExtensionJobs(); startWhatsappWorker(); startMatchingJobs().catch((err) => console.error('[matching] schedule failed', err)); startRetentionJobs().catch((err) => console.error('[retention] schedule failed', err)); diff --git a/backend/src/services/cohorts.js b/backend/src/services/cohorts.js index 0c46df0..bcb67c0 100644 --- a/backend/src/services/cohorts.js +++ b/backend/src/services/cohorts.js @@ -1,8 +1,10 @@ import { Cohort } from '../models/Cohort.js'; +import { CohortSession } from '../models/CohortSession.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { addPatientToWhatsapp, removePatientFromWhatsapp, syncWhatsappMembership } from './whatsapp.js'; import { applyOnboardedPii, hashMobile, isMobileNumber } from './cryptoPii.js'; -import { toYmd, weekdayOf } from './schedule.js'; +import { env } from '../config/env.js'; +import { toYmd, todayYmd, weekdayOf } from './schedule.js'; import Boom from '@hapi/boom'; async function safeWhatsapp(label, fn) { @@ -23,9 +25,17 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) { { $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } }, ); } + const added = nextIds.filter((id) => !prevIds.includes(id)); + if (added.length) { + await OnboardedPatient.updateMany({ _id: { $in: added } }, { $set: { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } }); + } const removed = prevIds.filter((id) => !nextIds.includes(id)); for (const id of removed) { + await CohortSession.updateMany( + { cohort: cohort._id, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } }, + { $pull: { records: { patient: id } } }, + ); const other = await Cohort.findOne({ _id: { $ne: cohort._id }, patients: id }); if (other) { await OnboardedPatient.updateOne( @@ -52,13 +62,19 @@ export async function syncCohortPatients(cohort, previousPatientIds = []) { export async function addPatientToCohort(cohort, patientId) { const id = String(patientId); const current = (cohort.patients || []).map((item) => String(item._id || item)); - if (!current.includes(id)) { + const joined = !current.includes(id); + if (joined) { cohort.patients.push(patientId); await cohort.save(); } await OnboardedPatient.updateOne( { _id: patientId }, - { $set: { assignedEducator: cohort.virtualPrt, cohort: cohort._id, matchingStatus: 'assigned' } }, + { $set: { + assignedEducator: cohort.virtualPrt, + cohort: cohort._id, + matchingStatus: 'assigned', + ...(joined ? { cohortJoinedOn: todayYmd(env.APP_TIMEZONE) } : {}), + } }, ); await safeWhatsapp('add patient', () => addPatientToWhatsapp(cohort, patientId)); } @@ -66,6 +82,10 @@ export async function addPatientToCohort(cohort, patientId) { export async function removePatientFromCohort(cohortId, patientId) { if (!cohortId || !patientId) return; await Cohort.updateOne({ _id: cohortId }, { $pull: { patients: patientId } }); + await CohortSession.updateMany( + { cohort: cohortId, status: 'scheduled', date: { $gte: todayYmd(env.APP_TIMEZONE) } }, + { $pull: { records: { patient: patientId } } }, + ); await safeWhatsapp('remove patient', () => removePatientFromWhatsapp(cohortId, patientId)); } diff --git a/backend/src/services/programSessions.js b/backend/src/services/programSessions.js index 35ae1ed..dfa549e 100644 --- a/backend/src/services/programSessions.js +++ b/backend/src/services/programSessions.js @@ -69,6 +69,27 @@ function manualSessionCount(cohort) { return cohort.sixDayWeek ? 60 : 30; } +// Program length for a cohort: its schedule count, otherwise the settings count, or 60 for a six-day manual cohort. +export async function cohortProgramLength(cohort, settings) { + const ownCount = cohort ? await scheduleSessionCount(cohort.scheduleId) : null; + return Math.min(Math.max(Number(manualSessionCount(cohort) || ownCount || settings?.upcomingSessionCount) || 30, 1), 60); +} + +// Completed sessions this patient was on, across every cohort. +async function finishedSessionCounts(patientIds) { + const ids = patientIds.map(String); + const counts = new Map(ids.map((id) => [id, 0])); + if (!ids.length) return counts; + const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: 'completed' }).select('records.patient'); + for (const session of sessions) { + for (const record of session.records || []) { + const id = String(record.patient); + if (counts.has(id)) counts.set(id, counts.get(id) + 1); + } + } + return counts; +} + // Creates the session list once. If rows exist, only adds dates when the list is short. export async function persistCohortSessions(cohort) { const settings = await getAppSettings(); @@ -223,21 +244,32 @@ export async function rescheduleOpenSessions(cohort) { return sessions; } +// Books each patient onto the upcoming sessions they still have left. Finished sessions are not changed. export async function ensureSessionRecords(cohort) { const patients = patientIds(cohort).map(String); if (!patients.length) return; - const sessions = await CohortSession.find({ cohort: cohort._id, status: { $in: ['scheduled', 'completed'] } }); - for (const session of sessions) { - const have = new Set((session.records || []).map((record) => String(record.patient))); - let dirty = false; - for (const id of patients) { - if (!have.has(id)) { + const settings = await getAppSettings(); + const length = await cohortProgramLength(cohort, settings); + const finished = await finishedSessionCounts(patients); + const today = todayYmd(env.APP_TIMEZONE); + const sessions = await CohortSession.find({ cohort: cohort._id, status: 'scheduled', date: { $gte: today } }).sort({ date: 1, index: 1 }); + const dirty = new Set(); + for (const id of patients) { + const remaining = Math.max(0, length - (finished.get(id) || 0)); + const keep = new Set(sessions.slice(0, remaining).map((session) => String(session._id))); + for (const session of sessions) { + const index = (session.records || []).findIndex((record) => String(record.patient) === id); + const should = keep.has(String(session._id)); + if (should && index < 0) { session.records.push({ patient: id, attended: false, pre: {}, post: {} }); - dirty = true; + dirty.add(session); + } else if (!should && index >= 0) { + session.records.splice(index, 1); + dirty.add(session); } } - if (dirty) await session.save(); } + for (const session of dirty) await session.save(); } // Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave. @@ -285,6 +317,37 @@ export async function nextAppendDate(cohort) { return cursor; } +// Adds today's session to open cohorts with no future session left, when today is their next meeting day. +export async function appendTodaySessions(date) { + const cohorts = await Cohort.find(liveCohortFilter()); + let added = 0; + for (const cohort of cohorts) { + if (await CohortSession.exists({ cohort: cohort._id, date })) continue; + if (await CohortSession.exists({ cohort: cohort._id, status: 'scheduled', date: { $gt: date } })) continue; + if (await nextAppendDate(cohort) !== date) continue; + const ids = patientIds(cohort).map(String); + const length = await cohortProgramLength(cohort, await getAppSettings()); + const finished = await finishedSessionCounts(ids); + if (!ids.some((id) => (finished.get(id) || 0) < length)) continue; // everyone already finished their sessions + const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ index: -1 }); + const link = await CohortSession.findOne({ cohort: cohort._id, meetUrl: { $ne: '' } }) || {}; + await CohortSession.create({ + cohort: cohort._id, + index: (last?.index || 0) + 1, + date, + slot: cohort.slot, + status: 'scheduled', + googleEventId: link.googleEventId || '', + meetUrl: link.meetUrl || '', + htmlLink: link.htmlLink || '', + records: [], + }); + await ensureSessionRecords(cohort); + added += 1; + } + return added; +} + async function sessionHost(session, cohort) { const coverId = session.coverPrt?._id || session.coverPrt; if (coverId) { @@ -322,6 +385,7 @@ export async function pushSession(session, reason, { notifyAdmins = false } = {} htmlLink: session.htmlLink || '', records: recordsFor(patientIds(cohort)), }); + await ensureSessionRecords(cohort); const admins = notifyAdmins ? await adminUsers() : []; await notifySessionPushed({ @@ -621,10 +685,35 @@ export async function attendanceProgress(patientId) { attended += 1; } } - return { attended, total: sessions.length }; + const patient = await OnboardedPatient.findById(patientId).populate('cohort', 'createdAs sixDayWeek scheduleId'); + const settings = await getAppSettings(); + const total = await cohortProgramLength(patient?.cohort, settings); + return { attended, total }; +} + +// Each patient's own session number on each session, counted by date across all their cohorts. +export async function patientSessionNumbers(sessions) { + const ids = [...new Set(sessions.flatMap((session) => (session.records || []).map((record) => String(record.patient?._id || record.patient))))]; + const numbers = new Map(); + if (!ids.length) return numbers; + const all = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $ne: 'cancelled' } }) + .select('_id date index records.patient') + .sort({ date: 1, index: 1 }); + const seen = new Map(); + for (const session of all) { + for (const record of session.records || []) { + const id = String(record.patient); + if (!ids.includes(id)) continue; + const next = (seen.get(id) || 0) + 1; + seen.set(id, next); + numbers.set(`${session._id}:${id}`, next); + } + } + return numbers; } export async function saveSessionRecords(session, records = [], { completed } = {}) { + const numbers = await patientSessionNumbers([session]); const byPatient = new Map((session.records || []).map((record) => [String(record.patient._id || record.patient), record])); for (const incoming of records) { const id = String(incoming.patientId || incoming.patient); @@ -644,7 +733,7 @@ export async function saveSessionRecords(session, records = [], { completed } = pulse: incoming.post.pulse ?? current.post?.pulse ?? null, }; } - if (incoming.assessment && (session.index === 15 || session.index === 30)) { + if (incoming.assessment && [15, 30].includes(numbers.get(`${session._id}:${id}`))) { const previous = current.assessment || {}; current.assessment = { fev1: incoming.assessment.fev1 ?? previous.fev1 ?? '', @@ -726,13 +815,15 @@ export async function moveSessionEvent(session, nextDate) { export async function patientProgramStats(patientIds) { const ids = patientIds.map((id) => String(id)); const sessions = await CohortSession.find({ 'records.patient': { $in: ids }, status: { $in: ['scheduled', 'completed'] } }); - const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, vitals: [] }])); + const byPatient = new Map(ids.map((id) => [id, { attended: 0, total: 0, completed: 0, assigned: 0, firstDate: '', vitals: [] }])); for (const session of sessions) { for (const record of session.records || []) { const id = String(record.patient); const row = byPatient.get(id); if (!row) continue; - row.total += 1; + if (session.status === 'completed') row.completed += 1; + else row.assigned += 1; + if (!row.firstDate || session.date < row.firstDate) row.firstDate = session.date; if (record.attended) row.attended += 1; if (record.pre?.spo2 != null || record.post?.spo2 != null || record.pre?.pulse != null) { row.vitals.push({ @@ -743,6 +834,19 @@ export async function patientProgramStats(patientIds) { } } } + const settings = await getAppSettings(); + const people = await OnboardedPatient.find({ _id: { $in: ids } }).populate('cohort', 'createdAs sixDayWeek scheduleId'); + const lengths = new Map(); + for (const person of people) { + const id = String(person._id); + const row = byPatient.get(id); + if (!row) continue; + const key = person.cohort ? String(person.cohort._id) : ''; + if (!lengths.has(key)) lengths.set(key, await cohortProgramLength(person.cohort, settings)); + const total = lengths.get(key); + row.total = total; + row.assigned = Math.max(0, total - row.completed); + } return byPatient; } diff --git a/backend/src/utils/serialize.js b/backend/src/utils/serialize.js index 4db243a..2a24137 100644 --- a/backend/src/utils/serialize.js +++ b/backend/src/utils/serialize.js @@ -184,6 +184,7 @@ export function toOnboardedPatientDto(item) { diseaseType: item.diseaseType || '', preferredWeekday: item.preferredWeekday ?? null, sessionDate: item.sessionDate || null, + cohortJoinedOn: item.cohortJoinedOn || '', slot: item.slot || '', matchingStatus: item.matchingStatus || 'unassigned', rejectReason: item.rejectReason || '', @@ -310,7 +311,7 @@ export function toLeaveDto(leave) { }; } -export function toCohortSessionDto(session) { +export function toCohortSessionDto(session, numbers = null) { if (!session) return null; const cohort = session.cohort && typeof session.cohort === 'object' ? session.cohort : null; return { @@ -343,6 +344,7 @@ export function toCohortSessionDto(session) { ? { id: String(record.patient._id), name: readOnboardedPii(record.patient).name, mobile: readOnboardedPii(record.patient).mobile } : { id: String(record.patient) }, attended: Boolean(record.attended), + patientSessionNo: numbers instanceof Map ? (numbers.get(`${session._id}:${String(record.patient?._id || record.patient)}`) ?? null) : null, // .map() passes an index here pre: { bp: record.pre?.bp || '', spo2: record.pre?.spo2 ?? null, pulse: record.pre?.pulse ?? null }, post: { bp: record.post?.bp || '', spo2: record.post?.spo2 ?? null, pulse: record.post?.pulse ?? null }, assessment: { diff --git a/frontend/src/components/SessionVitalsModal.jsx b/frontend/src/components/SessionVitalsModal.jsx index ad16dbd..503ac2f 100644 --- a/frontend/src/components/SessionVitalsModal.jsx +++ b/frontend/src/components/SessionVitalsModal.jsx @@ -46,6 +46,7 @@ export function rosterFromSession(session) { patientId: record.patient?.id, name: record.patient?.name || 'Patient', attended: Boolean(record.attended), + sessionNo: record.patientSessionNo ?? null, pre: { ...emptyVitals(), ...record.pre, spo2: record.pre?.spo2 ?? '', pulse: record.pre?.pulse ?? '' }, post: { ...emptyVitals(), ...record.post, spo2: record.post?.spo2 ?? '', pulse: record.post?.pulse ?? '' }, assessment: { ...emptyAssessment(), ...record.assessment }, @@ -101,7 +102,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved
- Attendance and vitals{isMilestone(roster?.index) ? ` · session ${roster.index}` : ''} + Attendance and vitals{roster?.index ? ` · cohort session ${roster.index}` : ''}
@@ -109,7 +110,7 @@ export default function SessionVitalsModal({ roster, onChange, onClose, onSaved {(roster?.records || []).length ? (roster.records || []).map((record, index) => (
- {record.name} + {record.name}{record.sessionNo ? session {record.sessionNo} : null} ))} - {isMilestone(roster?.index) ? ( + {isMilestone(record.sessionNo) ? ( {ASSESSMENT_FIELDS.map(([key, label]) => ( diff --git a/frontend/src/views/virtualpr/onboarding/index.jsx b/frontend/src/views/virtualpr/onboarding/index.jsx index b2df15b..a120f13 100644 --- a/frontend/src/views/virtualpr/onboarding/index.jsx +++ b/frontend/src/views/virtualpr/onboarding/index.jsx @@ -124,7 +124,7 @@ export default function OnboardingPage() { columnHelper.accessor('mobile', { header: 'Mobile' }), columnHelper.accessor((r) => r.cohort?.name || (r.matchingStatus === 'held' ? 'Held' : 'Pool'), { id: 'cohort', header: 'Cohort' }), columnHelper.accessor((r) => r.diseaseType || '—', { id: 'diseaseType', header: 'Category' }), - columnHelper.accessor((r) => formatDate(r.sessionDate), { id: 'sessionDate', header: 'Start date' }), + columnHelper.accessor((r) => formatDate(r.progress?.firstDate || r.sessionDate), { id: 'sessionDate', header: 'Start date' }), columnHelper.accessor('slot', { header: 'Slot' }), columnHelper.accessor('paymentStatus', { header: 'Payment' }), columnHelper.accessor((r) => r.progress ? `${r.progress.attended}/${r.progress.total}` : '—', { id: 'progress', header: 'Sessions' }), diff --git a/frontend/src/views/virtualpr/program-sessions/index.jsx b/frontend/src/views/virtualpr/program-sessions/index.jsx index 95d272a..1b74170 100644 --- a/frontend/src/views/virtualpr/program-sessions/index.jsx +++ b/frontend/src/views/virtualpr/program-sessions/index.jsx @@ -20,8 +20,13 @@ export default function ProgramSessionsPage() { load().catch((err) => setError(err.message)); }, []); - const openRoster = (session) => { - setRoster(rosterFromSession(session)); + const openRoster = async (session) => { + setError(''); + try { + setRoster(rosterFromSession(await api(`/api/cohort-sessions/${session.id}`))); + } catch (err) { + setError(err.message); + } }; return ( diff --git a/frontend/src/views/virtualpr/reports/index.jsx b/frontend/src/views/virtualpr/reports/index.jsx index 6d43ef6..60476e5 100644 --- a/frontend/src/views/virtualpr/reports/index.jsx +++ b/frontend/src/views/virtualpr/reports/index.jsx @@ -42,6 +42,7 @@ export default function ReportsPage() {
+
@@ -49,17 +50,20 @@ export default function ReportsPage() {
Patient progress
- + {patients.map((item) => ( + + + ))} - {!patients.length ? : null} + {!patients.length ? : null}
PatientCohortPRTAttendance
PatientCohortPRTFirst sessionCompletedAssignedAttendance
{item.name}
{item.diseaseType}
{item.cohort?.name || '—'} {item.prt ? `${item.prt.firstName} ${item.prt.lastName}` : '—'}{item.firstSession || '—'}{item.completed}{item.assigned} {item.completion}
No confirmed patients.
No confirmed patients.