diff --git a/backend/src/models/Cohort.js b/backend/src/models/Cohort.js index 3ef3681..b003f67 100644 --- a/backend/src/models/Cohort.js +++ b/backend/src/models/Cohort.js @@ -6,6 +6,7 @@ const cohortSchema = new mongoose.Schema( startDate: { type: Date, required: true }, slot: { type: String, required: true, trim: true }, language: { type: String, default: '', trim: true }, + scheduleId: { type: String, default: '', trim: true }, // blank means Normal; otherwise an extra schedule id diseaseType: { type: String, default: '', trim: true }, status: { type: String, diff --git a/backend/src/models/CohortSession.js b/backend/src/models/CohortSession.js index 51751fe..7be5faf 100644 --- a/backend/src/models/CohortSession.js +++ b/backend/src/models/CohortSession.js @@ -17,6 +17,8 @@ const assessmentSchema = new mongoose.Schema( eq5d3l: { type: String, default: '', trim: true }, sgrq: { type: String, default: '', trim: true }, sitToStand: { type: String, default: '', trim: true }, + rpeRest: { type: String, default: '', trim: true }, + rpeActivity: { type: String, default: '', trim: true }, o2Regular: { type: String, default: '', trim: true }, o2WithExercise: { type: String, default: '', trim: true }, }, diff --git a/backend/src/models/Settings.js b/backend/src/models/Settings.js index 89bc793..4eca159 100644 --- a/backend/src/models/Settings.js +++ b/backend/src/models/Settings.js @@ -50,6 +50,15 @@ const settingsSchema = new mongoose.Schema( matchingPriority: { type: [String], default: ['language', 'slot', 'weekday', 'disease'] }, matchingCron: { type: String, default: '0 7 * * 6' }, piiRetentionDays: { type: Number, default: 365 }, + cohortSchedules: { // extra schedules, each with its own weekdays and meeting count + type: [{ + id: { type: String, required: true, trim: true }, + name: { type: String, required: true, trim: true }, + weekdays: { type: [Number], default: [] }, + upcomingSessionCount: { type: Number, default: 30 }, + }], + default: [], + }, whatsappAdminIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], whatsappReminderRules: { type: [reminderRuleSchema], default: [] }, whatsappTaskGapMinMs: { type: Number, default: 15000 }, diff --git a/backend/src/routes/cohorts.js b/backend/src/routes/cohorts.js index dbe26ed..d263412 100644 --- a/backend/src/routes/cohorts.js +++ b/backend/src/routes/cohorts.js @@ -6,10 +6,10 @@ import { Role } from '../models/Role.js'; import { User } from '../models/User.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { toCohortDto, userLanguages } from '../utils/serialize.js'; -import { assertCohortStartDate, buildCohortName, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js'; +import { assertCohortStartDate, buildCohortName, scheduleSessionCount, scheduleWeekdays, toYmd, upcomingSessionDates, ymdToDate } from '../services/schedule.js'; import { releaseCohortPatients, syncCohortPatients } from '../services/cohorts.js'; import { activateManualCohort } from '../services/matching.js'; -import { persistCohortSessions } from '../services/programSessions.js'; +import { ensureSessionRecords, persistCohortSessions, rescheduleOpenSessions } from '../services/programSessions.js'; import { CohortSession } from '../models/CohortSession.js'; import { assertConfiguredSlot, getAppSettings } from '../services/settings.js'; import { toCohortSessionDto } from '../utils/serialize.js'; @@ -25,8 +25,18 @@ const cohortPayload = Joi.object({ diseaseType: Joi.string().trim().allow('', null), patients: Joi.array().items(objectId).default([]), durationMinutes: Joi.number().min(15).max(180).optional(), + scheduleId: Joi.string().trim().allow('', null), }); +// Saved extra-schedule id to its weekdays. Blank means Normal. +async function chosenWeekdays(scheduleId) { + const id = String(scheduleId || '').trim(); + if (!id) return null; + const days = await scheduleWeekdays(id); + if (!days) throw Boom.badRequest('Select a schedule from Settings.'); + return days; +} + async function assertVirtualPrt(userId) { const user = await User.findById(userId).populate('role'); if (!user || !user.isActive) throw Boom.badRequest('Invalid Virtual PRT'); @@ -113,6 +123,7 @@ export function cohortRoutes(server) { }, handler: async (request) => { const { startDate, slot, virtualPrt, patients } = request.payload; + const scheduleId = String(request.payload.scheduleId || '').trim(); const settings = await getAppSettings(); const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60; await assertConfiguredSlot(slot); @@ -126,7 +137,7 @@ export function cohortRoutes(server) { throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`); } try { - await assertCohortStartDate(startDate, virtualPrt, language); + await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId)); } catch (err) { throw Boom.badRequest(err.message); } @@ -136,6 +147,7 @@ export function cohortRoutes(server) { startDate: ymdToDate(startDate), slot, language, + scheduleId, diseaseType: request.payload.diseaseType || '', status: 'active', virtualPrt, @@ -168,14 +180,15 @@ export function cohortRoutes(server) { pre: [{ method: requirePermission('cohorts', 'view') }], validate: { params: Joi.object({ id: objectId.required() }), - query: Joi.object({ count: Joi.number().min(1).max(40) }), + query: Joi.object({ count: Joi.number().min(1).max(60) }), }, }, handler: async (request) => { const cohort = await findCohort(request); const stored = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 }); if (stored.length) return { items: stored.map(toCohortSessionDto) }; - const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt?._id || cohort.virtualPrt, request.query.count, cohort.language); + const ownCount = await scheduleSessionCount(cohort.scheduleId); + const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt?._id || cohort.virtualPrt, ownCount || request.query.count, cohort.language, await scheduleWeekdays(cohort.scheduleId)); return { items: dates.map((date) => ({ date, slot: cohort.slot, status: 'scheduled' })) }; }, }); @@ -196,7 +209,14 @@ export function cohortRoutes(server) { const previousName = cohort.name; const previousPrtId = String(cohort.virtualPrt?._id || cohort.virtualPrt || ''); const hadGroup = Boolean(cohort.whatsappGroupJid); + const previousStart = toYmd(cohort.startDate); + const previousSchedule = String(cohort.scheduleId || '').trim(); + const previousSlot = String(cohort.slot || ''); const { startDate, slot, virtualPrt, patients } = request.payload; + const scheduleId = String(request.payload.scheduleId || '').trim(); + const scheduleChanged = previousStart !== String(startDate) + || previousSchedule !== scheduleId + || previousSlot !== String(slot || ''); const settings = await getAppSettings(); const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60; await assertConfiguredSlot(slot); @@ -210,7 +230,7 @@ export function cohortRoutes(server) { throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`); } try { - await assertCohortStartDate(startDate, virtualPrt, language); + await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId)); } catch (err) { throw Boom.badRequest(err.message); } @@ -218,6 +238,7 @@ export function cohortRoutes(server) { cohort.startDate = ymdToDate(startDate); cohort.slot = slot; cohort.language = language; + cohort.scheduleId = scheduleId; cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || ''; cohort.virtualPrt = virtualPrt; cohort.patients = patients; @@ -229,7 +250,9 @@ export function cohortRoutes(server) { await onCohortUpdated(cohort._id, { previousName, previousPrtId, hadGroup }).catch((err) => { console.error(`[whatsapp] cohort update failed: ${err?.message || err}`); }); - await persistCohortSessions(cohort); + if (scheduleChanged) await rescheduleOpenSessions(cohort); + else if (await CohortSession.countDocuments({ cohort: cohort._id })) await ensureSessionRecords(cohort); + else await persistCohortSessions(cohort); const ready = await Cohort.findById(cohort._id) .populate('virtualPrt', 'firstName lastName email') .populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' }); diff --git a/backend/src/routes/matching.js b/backend/src/routes/matching.js index 3759c59..404ec44 100644 --- a/backend/src/routes/matching.js +++ b/backend/src/routes/matching.js @@ -19,6 +19,7 @@ import { resolveReassignment, retargetProposal, runMatching, + setProposalSchedule, } from '../services/matching.js'; const objectId = Joi.string().hex().length(24); @@ -136,6 +137,21 @@ export function matchingRoutes(server) { }, }); + server.route({ + method: 'PATCH', + path: '/api/matching/proposals/{id}/schedule', + options: { + pre: [{ method: requirePermission('matching', 'edit') }], + validate: { + params: Joi.object({ id: objectId.required() }), + payload: Joi.object({ + scheduleId: Joi.string().trim().allow('').required(), + }), + }, + }, + handler: async (request) => setProposalSchedule(request.params.id, request.payload.scheduleId), + }); + server.route({ method: 'POST', path: '/api/matching/proposals/{id}/confirm', diff --git a/backend/src/routes/onboarding.js b/backend/src/routes/onboarding.js index 67459cb..4ee9823 100644 --- a/backend/src/routes/onboarding.js +++ b/backend/src/routes/onboarding.js @@ -262,10 +262,11 @@ export function onboardingRoutes(server) { const searchFilter = {}; if (search) { const term = String(search).trim(); - const hashes = [hashMobile(term), hashName(term)].filter(Boolean); + const mobileHash = hashMobile(term); + const nameHash = hashName(term); searchFilter.$or = []; - if (hashes[0]) searchFilter.$or.push({ mobileHash: hashes[0] }); - if (hashes[1]) searchFilter.$or.push({ nameHash: hashes[1] }); + if (mobileHash) searchFilter.$or.push({ mobileHash }); + if (nameHash) searchFilter.$or.push({ nameHash }); if (!searchFilter.$or.length) searchFilter.$or.push({ _id: null }); } const filter = mergeFilters(await onboardingScopeFilter(request), extra, searchFilter); diff --git a/backend/src/routes/programSessions.js b/backend/src/routes/programSessions.js index 763ffe6..035babc 100644 --- a/backend/src/routes/programSessions.js +++ b/backend/src/routes/programSessions.js @@ -179,6 +179,8 @@ export function programSessionRoutes(server) { eq5d3l: Joi.string().allow('', null), sgrq: Joi.string().allow('', null), sitToStand: Joi.string().allow('', null), + rpeRest: Joi.string().allow('', null), + rpeActivity: Joi.string().allow('', null), o2Regular: Joi.string().allow('', null), o2WithExercise: Joi.string().allow('', null), }), diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index ed5c4e1..2d0d9ee 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -29,6 +29,12 @@ function toCsv(rows) { return rows.map((row) => row.map(csvEscape).join(',')).join('\n'); } +// P when that patient attended the meeting, A when they did not. +function sessionMark(session, patientId) { + const record = (session.records || []).find((item) => String(item.patient?._id || item.patient) === patientId); + return record?.attended ? 'P' : 'A'; +} + export function reportRoutes(server) { server.route({ method: 'GET', @@ -195,17 +201,35 @@ export function reportRoutes(server) { rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items]; } else { const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); - const stats = await patientProgramStats(patients.map((item) => item._id)); - rows = [['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Attendance'], ...patients.map((patient) => { - const progress = stats.get(String(patient._id)) || { attended: 0, total: 0 }; + 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') + .sort({ index: 1, date: 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 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}`)]; + rows = [headers, ...patients.map((patient) => { const pii = readOnboardedPii(patient); + const cohortId = patient.cohort?._id ? String(patient.cohort._id) : ''; + const meetings = cohortId ? (byCohort.get(cohortId) || []) : []; + 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 [ pii.name, pii.mobile, patient.diseaseType || '', patient.cohort?.name || '', patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '', - `${progress.attended}/${progress.total}`, + patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '', + ...marks, ]; })]; } diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index d7c8a80..6b77bb2 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -1,6 +1,7 @@ import Joi from 'joi'; import Boom from '@hapi/boom'; import { credentials } from '../plugins/auth.js'; +import { Cohort } from '../models/Cohort.js'; import { getAppSettings, normalizeSettingsPayload, toSettingsDto } from '../services/settings.js'; function requireSuperAdmin(request, h) { @@ -17,7 +18,7 @@ const settingsPayload = Joi.object({ slotDurationMinutes: Joi.number().min(15).max(180).required(), cohortStartWeekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(), repeatIntervalDays: Joi.number().integer().min(1).max(30).required(), - upcomingSessionCount: Joi.number().integer().min(1).max(40).required(), + upcomingSessionCount: Joi.number().integer().min(1).max(60).required(), defaultDurationMinutes: Joi.number().min(15).max(180).required(), skipHolidays: Joi.boolean().required(), skipPrtLeave: Joi.boolean().required(), @@ -30,6 +31,12 @@ const settingsPayload = Joi.object({ matchingPriority: Joi.array().items(Joi.string().trim().min(1)).min(1).required(), matchingCron: Joi.string().trim().min(1).required(), piiRetentionDays: Joi.number().integer().min(1).max(3650).optional(), + cohortSchedules: Joi.array().items(Joi.object({ + id: Joi.string().trim().allow('', null), + name: Joi.string().trim().min(1).required(), + weekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(), + upcomingSessionCount: Joi.number().integer().min(1).max(60).required(), + })).default([]), }); export function settingsRoutes(server) { @@ -48,7 +55,15 @@ export function settingsRoutes(server) { }, handler: async (request) => { const settings = await getAppSettings(); - Object.assign(settings, normalizeSettingsPayload(request.payload)); + const next = normalizeSettingsPayload(request.payload); + const removed = (settings.cohortSchedules || []) + .map((row) => row.id) + .filter((id) => !next.cohortSchedules.some((row) => row.id === id)); + if (removed.length) { + const used = await Cohort.countDocuments({ scheduleId: { $in: removed } }); + if (used) throw Boom.badRequest('A cohort is still using a schedule you removed.'); + } + Object.assign(settings, next); await settings.save(); return toSettingsDto(settings); }, diff --git a/backend/src/services/google.js b/backend/src/services/google.js index c7e4642..5983b4e 100644 --- a/backend/src/services/google.js +++ b/backend/src/services/google.js @@ -234,12 +234,14 @@ export async function createCalendarEvent(user, { } } +// Updates an existing Google event and keeps the same Meet link. No new invite. export async function updateCalendarEvent(user, eventId, { title, start, end, timeZone, description = '', + recurrence = null, }) { if (!eventId) return null; if (!user.google?.refreshToken && !user.google?.accessToken) { @@ -247,16 +249,19 @@ export async function updateCalendarEvent(user, eventId, { } const auth = oauthClientFor(user); const calendar = google.calendar({ version: 'v3', auth }); + const requestBody = { + summary: title, + description: description || undefined, + start: { dateTime: start, timeZone }, + end: { dateTime: end, timeZone }, + }; + if (Array.isArray(recurrence) && recurrence.length) requestBody.recurrence = recurrence; try { const { data } = await calendar.events.patch({ calendarId: 'primary', eventId, - requestBody: { - summary: title, - description: description || undefined, - start: { dateTime: start, timeZone }, - end: { dateTime: end, timeZone }, - }, + sendUpdates: 'none', + requestBody, }); return { id: data.id, diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index 3d90a04..ffca1a5 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -7,7 +7,7 @@ import { User } from '../models/User.js'; import { addPatientToCohort, removePatientFromCohort, syncCohortPatients } from './cohorts.js'; import { notifyCohortConfirmed, notifyReassignment } from './notify.js'; import { persistCohortSessions } from './programSessions.js'; -import { addDays, assertCohortStartDate, buildCohortName, toYmd, todayYmd, weekdayOf, ymdToDate } from './schedule.js'; +import { addDays, assertCohortStartDate, buildCohortName, scheduleWeekdays, toYmd, todayYmd, upcomingSessionDates, weekdayOf, ymdToDate } from './schedule.js'; import { assertConfiguredSlot, getAppSettings, slotLabel, WEEKDAY_LABELS } from './settings.js'; import { readOnboardedPii } from './cryptoPii.js'; import { toCohortDto, userLanguages } from '../utils/serialize.js'; @@ -425,12 +425,45 @@ async function fieldNoticesForCohort(cohort) { return [...seen.values()]; } +// Sets the schedule on a proposed cohort and moves the start if that weekday is not allowed. +export async function setProposalSchedule(proposalId, scheduleId) { + const cohort = await Cohort.findById(proposalId); + if (!cohort) throw Boom.notFound('Proposed cohort not found'); + if (cohort.status !== 'proposed') throw Boom.badRequest('Only a proposed cohort can change schedule.'); + const id = String(scheduleId || '').trim(); + const weekdays = id ? await scheduleWeekdays(id) : null; + if (id && !weekdays) throw Boom.badRequest('Select a schedule from Settings.'); + cohort.scheduleId = id; + if (weekdays && !weekdays.includes(weekdayOf(toYmd(cohort.startDate)))) { + const dates = await upcomingSessionDates(toYmd(cohort.startDate), cohort.virtualPrt, 1, cohort.language, weekdays); + if (!dates[0]) throw Boom.badRequest('No date matches this schedule.'); + cohort.startDate = ymdToDate(dates[0]); + cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: dates[0] }); + } + await cohort.save(); + await OnboardedPatient.updateMany({ cohort: cohort._id }, { $set: { sessionDate: cohort.startDate } }); + const ready = await Cohort.findById(cohort._id) + .populate('virtualPrt', 'firstName lastName email') + .populate({ path: 'patients', select: 'name mobile whatsappOptOut' }); + return toCohortDto(ready); +} + +// Confirms a proposal, moves a Sunday or past start onto a valid day, then creates sessions. export async function confirmCohort(cohortId) { const cohort = await Cohort.findById(cohortId).populate('virtualPrt').populate('patients'); if (!cohort) throw Boom.notFound('Cohort not found'); if (cohort.status !== 'proposed') throw Boom.badRequest('Only proposed cohorts can be confirmed from matching.'); const startYmd = toYmd(cohort.startDate); - if (startYmd < todayYmd(env.APP_TIMEZONE)) { + const weekdays = await scheduleWeekdays(cohort.scheduleId); + const today = todayYmd(env.APP_TIMEZONE); + const startsOnSunday = weekdayOf(startYmd) === 0; + if (startsOnSunday || (weekdays && startYmd < today)) { + const from = startYmd < today ? today : startYmd; + const dates = await upcomingSessionDates(from, cohort.virtualPrt?._id || cohort.virtualPrt, 1, cohort.language, weekdays); + if (!dates[0]) throw Boom.badRequest('No upcoming date matches this schedule.'); + cohort.startDate = ymdToDate(dates[0]); + cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: dates[0] }); + } else if (startYmd < today) { cohort.startDate = ymdToDate(nextStartDateForWeekday(weekdayOf(startYmd))); cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: toYmd(cohort.startDate) }); } diff --git a/backend/src/services/programSessions.js b/backend/src/services/programSessions.js index 933f922..e06c96b 100644 --- a/backend/src/services/programSessions.js +++ b/backend/src/services/programSessions.js @@ -15,6 +15,9 @@ import { toYmd, todayYmd, upcomingSessionDates, + scheduleSessionCount, + scheduleWeekdays, + weekdayOf, } from './schedule.js'; import { getAppSettings } from './settings.js'; import { toCohortSessionDto } from '../utils/serialize.js'; @@ -60,9 +63,12 @@ function recordsFor(ids) { return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} })); } +// 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(); - const needed = Math.min(Math.max(Number(settings.upcomingSessionCount) || 30, 1), 40); + const ownCount = await scheduleSessionCount(cohort.scheduleId); + const needed = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60); + const weekdays = await scheduleWeekdays(cohort.scheduleId); const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 }); const active = existing.filter((session) => session.status !== 'cancelled'); const patients = patientIds(cohort); @@ -73,6 +79,7 @@ export async function persistCohortSessions(cohort) { cohort.virtualPrt?._id || cohort.virtualPrt, needed, cohort.language, + weekdays, ); if (!dates.length) return []; await CohortSession.insertMany(dates.map((date, index) => ({ @@ -105,6 +112,111 @@ export async function persistCohortSessions(cohort) { return CohortSession.find({ cohort: cohort._id }).sort({ index: 1 }); } +// Next dates from today or the new start, skipping Sunday, holiday, leave, and completed dates. +async function collectSessionDates(from, cohort, count, weekdays, blocked) { + const dates = []; + const prtId = cohort.virtualPrt?._id || cohort.virtualPrt; + let cursor = from; + for (let guard = 0; dates.length < count && guard < 8; guard += 1) { + const batch = await upcomingSessionDates( + cursor, + prtId, + Math.min(60, count - dates.length + blocked.size + 5), + cohort.language, + weekdays, + ); + if (!batch.length) break; + for (const date of batch) { + if (date < from || blocked.has(date) || dates.includes(date)) continue; + dates.push(date); + if (dates.length >= count) break; + } + const next = addDays(batch[batch.length - 1], 1); + if (next <= cursor) break; + cursor = next; + } + return dates.slice(0, count); +} + +// Moves the existing Google event onto the new dates. The Meet link stays. +async function syncExistingMeetSeries(cohort, sessions) { + const linked = sessions.find((session) => session.googleEventId); + if (!linked) return; + const open = sessions + .filter((session) => session.status !== 'completed') + .sort((a, b) => String(a.date).localeCompare(String(b.date))); + if (!open.length) return; + const host = await sessionHost(linked, cohort); + if (!host?.google?.refreshToken && !host?.google?.accessToken) return; + const settings = await getAppSettings(); + const weekdays = await scheduleWeekdays(cohort.scheduleId); + const first = open[0]; + const last = open[open.length - 1]; + const times = slotDateTimes(first.date, first.slot || cohort.slot, cohort.durationMinutes || settings.defaultDurationMinutes || 60); + try { + await updateCalendarEvent(host, linked.googleEventId, { + title: `PR Session ยท ${cohort.name}`, + description: `Pulmonary rehab repeat meetings for ${cohort.name} (${open.length} upcoming sessions).`, + start: times.start, + end: times.end, + timeZone: env.APP_TIMEZONE, + recurrence: sessionRecurrence({ + firstDate: first.date, + lastDate: last.date, + interval: settings.repeatIntervalDays, + startTime: times.startTime, + timeZone: env.APP_TIMEZONE, + includeDates: open.map((session) => session.date), + weekdays, + }), + }); + } catch (err) { + console.error(`[meet] series update failed: ${err?.message || err}`); + } +} + +// Replaces sessions that are not completed. Completed sessions stay. +export async function rescheduleOpenSessions(cohort) { + const settings = await getAppSettings(); + const ownCount = await scheduleSessionCount(cohort.scheduleId); + const cap = Math.min(Math.max(Number(ownCount || settings.upcomingSessionCount) || 30, 1), 60); + const weekdays = await scheduleWeekdays(cohort.scheduleId); + const existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 }); + const completed = existing.filter((session) => session.status === 'completed'); + const open = existing.filter((session) => session.status !== 'completed'); + const remaining = Math.max(0, cap - completed.length); + const today = todayYmd(env.APP_TIMEZONE); + const startYmd = toYmd(cohort.startDate); + const from = startYmd >= today ? startYmd : today; + const blocked = new Set(completed.map((session) => session.date)); + const dates = remaining + ? await collectSessionDates(from, cohort, remaining, weekdays, blocked) + : []; + const link = existing.find((session) => session.googleEventId) || {}; + if (open.length) await CohortSession.deleteMany({ _id: { $in: open.map((session) => session._id) } }); + let index = completed.reduce((max, session) => Math.max(max, session.index || 0), 0); + if (dates.length) { + await CohortSession.insertMany(dates.map((date) => { + index += 1; + return { + cohort: cohort._id, + index, + date, + slot: cohort.slot, + status: 'scheduled', + googleEventId: link.googleEventId || '', + meetUrl: link.meetUrl || '', + htmlLink: link.htmlLink || '', + records: recordsFor(patientIds(cohort)), + }; + })); + } + await ensureSessionRecords(cohort); + const sessions = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 }); + await syncExistingMeetSeries(cohort, sessions); + return sessions; +} + export async function ensureSessionRecords(cohort) { const patients = patientIds(cohort).map(String); if (!patients.length) return; @@ -122,7 +234,32 @@ export async function ensureSessionRecords(cohort) { } } +// Next date for an extra schedule, skipping unticked days, Sunday, holiday, and leave. +async function nextWeekdayAppendDate(cohort, weekdays) { + const settings = await getAppSettings(); + const allowed = new Set(weekdays.map(Number)); + const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 }); + let cursor = last?.date ? addDays(last.date, 1) : toYmd(cohort.startDate); + const horizon = addDays(cursor, 80); + const holidays = settings.skipHolidays !== false + ? await holidayDatesBetween(cursor, horizon, cohort.language) + : new Set(); + const prtId = cohort.virtualPrt?._id || cohort.virtualPrt; + for (let guard = 0; guard < 400; guard += 1) { + const weekdayBlocked = !allowed.has(weekdayOf(cursor)); + const sundayBlocked = weekdayOf(cursor) === 0; + const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor); + const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(prtId, cursor); + if (!weekdayBlocked && !sundayBlocked && !holidayBlocked && !leaveBlocked) return cursor; + cursor = addDays(cursor, 1); + } + return cursor; +} + +// Next date after the last session. Extra schedules use weekdays. Normal uses the interval. export async function nextAppendDate(cohort) { + const weekdays = await scheduleWeekdays(cohort.scheduleId); + if (weekdays) return nextWeekdayAppendDate(cohort, weekdays); const settings = await getAppSettings(); const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 }); @@ -133,9 +270,10 @@ export async function nextAppendDate(cohort) { : new Set(); const prtId = cohort.virtualPrt?._id || cohort.virtualPrt; for (let guard = 0; guard < 400; guard += 1) { + const sundayBlocked = weekdayOf(cursor) === 0; const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor); const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(prtId, cursor); - if (!holidayBlocked && !leaveBlocked) return cursor; + if (!sundayBlocked && !holidayBlocked && !leaveBlocked) return cursor; cursor = addDays(cursor, interval); } return cursor; @@ -273,6 +411,7 @@ export async function ensureMeetForCohort(cohort) { } const settings = await getAppSettings(); + const weekdays = await scheduleWeekdays(cohort.scheduleId); const first = sessions[0]; const last = sessions[sessions.length - 1]; const times = slotDateTimes(first.date, first.slot || cohort.slot, cohort.durationMinutes || settings.defaultDurationMinutes || 60); @@ -292,6 +431,7 @@ export async function ensureMeetForCohort(cohort) { startTime: times.startTime, timeZone: env.APP_TIMEZONE, includeDates: sessions.map((session) => session.date), + weekdays, }), }); await applyMeetToSessions(sessions, event, first.slot || cohort.slot); @@ -478,6 +618,8 @@ export async function saveSessionRecords(session, records = [], { completed } = eq5d3l: incoming.assessment.eq5d3l ?? previous.eq5d3l ?? '', sgrq: incoming.assessment.sgrq ?? previous.sgrq ?? '', sitToStand: incoming.assessment.sitToStand ?? previous.sitToStand ?? '', + rpeRest: incoming.assessment.rpeRest ?? previous.rpeRest ?? '', + rpeActivity: incoming.assessment.rpeActivity ?? previous.rpeActivity ?? '', o2Regular: incoming.assessment.o2Regular ?? previous.o2Regular ?? '', o2WithExercise: incoming.assessment.o2WithExercise ?? previous.o2WithExercise ?? '', }; @@ -575,7 +717,7 @@ export async function listOnboardedForReports({ virtualPrtId, diseaseType, cohor if (diseaseType) filter.diseaseType = diseaseType; if (cohortId) filter.cohort = cohortId; return OnboardedPatient.find(filter) - .populate('cohort', 'name slot language diseaseType virtualPrt') + .populate('cohort', 'name slot language diseaseType virtualPrt startDate') .populate('assignedEducator', 'firstName lastName email') .populate('onboardedBy', 'firstName lastName email') .sort({ name: 1 }); diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 333628c..2d5fdd9 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -73,9 +73,38 @@ export async function leaveCoversDate(userId, ymd) { return Boolean(leave); } -export async function assertCohortStartDate(startYmd, virtualPrtId, language) { +// Weekdays for an extra schedule. Blank id means Normal, which uses the repeat interval. +export async function scheduleWeekdays(scheduleId) { + const id = String(scheduleId || '').trim(); + if (!id) return null; const settings = await getAppSettings(); - if (!isAllowedStartWeekday(startYmd, settings.cohortStartWeekdays)) { + const row = (settings.cohortSchedules || []).find((item) => item.id === id); + if (!row) return null; + const days = [...new Set((row.weekdays || []).map(Number))].filter((day) => day >= 1 && day <= 6); + return days.length ? days : null; +} + +// Meeting cap for an extra schedule. Blank id uses the Normal meetings-per-cohort number. +export async function scheduleSessionCount(scheduleId) { + const id = String(scheduleId || '').trim(); + if (!id) return null; + const settings = await getAppSettings(); + const row = (settings.cohortSchedules || []).find((item) => item.id === id); + const count = Number(row?.upcomingSessionCount); + if (!Number.isFinite(count) || count < 1) return null; + return Math.min(Math.round(count), 60); +} + +// Rejects Sunday, a holiday, leave, or a weekday this schedule does not meet. +export async function assertCohortStartDate(startYmd, virtualPrtId, language, weekdays = null) { + if (weekdayOf(startYmd) === 0) throw new Error('A cohort cannot meet on Sunday.'); + const settings = await getAppSettings(); + const allowed = Array.isArray(weekdays) && weekdays.length ? weekdays.map(Number).filter((day) => day !== 0) : null; + if (allowed) { + if (!allowed.includes(weekdayOf(startYmd))) { + throw new Error(`This schedule only meets on ${formatWeekdays(allowed)}.`); + } + } else if (!isAllowedStartWeekday(startYmd, settings.cohortStartWeekdays)) { throw new Error(`A new cohort can only start on ${formatWeekdays(settings.cohortStartWeekdays)}.`); } if (settings.skipHolidays !== false) { @@ -119,8 +148,10 @@ export function slotDateTimes(date, slot, durationMinutes = 60) { return { startTime, endTime, start, end }; } +// True when the date is on the Normal interval and is not Sunday, a holiday, or leave. export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, language) { if (!startYmd || !targetYmd || targetYmd < startYmd) return false; + if (weekdayOf(targetYmd) === 0) return false; const settings = await getAppSettings(); const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); const start = parseYmd(startYmd); @@ -135,39 +166,63 @@ export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, langu return true; } -export async function upcomingSessionDates(startYmd, virtualPrtId, count, language) { +const RRULE_DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; + +// Builds meeting dates. Normal uses the interval. An extra schedule uses its weekdays. Sunday, holiday, and leave are skipped. +export async function upcomingSessionDates(startYmd, virtualPrtId, count, language, weekdays = null) { const settings = await getAppSettings(); - const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 40); - const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); + const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 60); + const allowed = Array.isArray(weekdays) && weekdays.length ? new Set(weekdays.map(Number)) : null; + const interval = allowed ? 1 : Math.max(1, Number(settings.repeatIntervalDays) || 2); const horizon = addDays(startYmd, needed * interval * 8); const holidays = settings.skipHolidays !== false ? await holidayDatesBetween(startYmd, horizon, language) : new Set(); const dates = []; let cursor = startYmd; let guard = 0; while (dates.length < needed && guard < 400) { + const sundayBlocked = weekdayOf(cursor) === 0; + const weekdayBlocked = allowed ? !allowed.has(weekdayOf(cursor)) : false; const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor); const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(virtualPrtId, cursor); - if (!holidayBlocked && !leaveBlocked) dates.push(cursor); + if (!weekdayBlocked && !sundayBlocked && !holidayBlocked && !leaveBlocked) dates.push(cursor); cursor = addDays(cursor, interval); guard += 1; } return dates; } -export function sessionRecurrence({ firstDate, lastDate, interval, startTime, timeZone, includeDates }) { +// Google repeat rule. Sunday is excluded. An extra schedule repeats weekly on its days. +export function sessionRecurrence({ firstDate, lastDate, interval, startTime, timeZone, includeDates, weekdays = null }) { const include = new Set(includeDates || []); - const step = Math.max(1, Number(interval) || 2); + const allowed = Array.isArray(weekdays) && weekdays.some((day) => Number(day) >= 1 && Number(day) <= 6) + ? new Set(weekdays.map(Number).filter((day) => day >= 1 && day <= 6)) + : null; + const step = allowed ? 1 : Math.max(1, Number(interval) || 2); const time = `${String(startTime || '09:00').replace(':', '')}00`.slice(0, 6); const stamp = (ymd) => `${String(ymd).replace(/-/g, '')}T${time}`; let cursor = firstDate; let count = 0; const exdates = []; - while (cursor <= lastDate && count < 80) { - count += 1; - if (include.size && !include.has(cursor)) exdates.push(cursor); + let guard = 0; + while (cursor <= lastDate && count < 120 && guard < 500) { + guard += 1; + const sunday = weekdayOf(cursor) === 0; + const weekdayBlocked = allowed ? !allowed.has(weekdayOf(cursor)) : false; + if (sunday && !allowed) { + count += 1; + exdates.push(cursor); + } else if (!weekdayBlocked && !sunday) { + count += 1; + if (include.size && !include.has(cursor)) exdates.push(cursor); + } cursor = addDays(cursor, step); } - const lines = [`RRULE:FREQ=DAILY;INTERVAL=${step};COUNT=${Math.max(count, 1)}`]; + const byDay = allowed + ? [...allowed].sort((a, b) => a - b).map((day) => RRULE_DAYS[day]).filter(Boolean).join(',') + : ''; + const lines = [byDay + ? `RRULE:FREQ=WEEKLY;BYDAY=${byDay};COUNT=${Math.max(count, 1)}` + : `RRULE:FREQ=DAILY;INTERVAL=${step};COUNT=${Math.max(count, 1)}`]; if (exdates.length) { lines.push(`EXDATE;TZID=${timeZone || 'Asia/Kolkata'}:${exdates.map(stamp).join(',')}`); } diff --git a/backend/src/services/settings.js b/backend/src/services/settings.js index 5079e27..98a6f82 100644 --- a/backend/src/services/settings.js +++ b/backend/src/services/settings.js @@ -1,4 +1,5 @@ import Boom from '@hapi/boom'; +import crypto from 'node:crypto'; import { Settings } from '../models/Settings.js'; const TIME = /^\d{2}:\d{2}$/; @@ -29,7 +30,7 @@ export const DEFAULT_SETTINGS = { defaultDurationMinutes: 60, skipHolidays: true, skipPrtLeave: true, - languages: ['Tamil', 'Telagu', 'Hindi', 'Malayalam', 'Bangali', 'Kannda', 'Marathi'], + languages: ['Tamil', 'Telugu', 'Hindi', 'Malayalam', 'Bengali', 'Kannada', 'Marathi'], paymentStatuses: ['Unpaid', 'Paid', 'Partial'], genders: ['Male', 'Female', 'Other'], diseaseTypes: ['ILD', 'OAD'], @@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS = { matchingPriority: ['language', 'slot', 'weekday', 'disease'], matchingCron: '0 7 * * 6', piiRetentionDays: 365, + cohortSchedules: [], whatsappAdminIds: [], whatsappReminderRules: [], whatsappTaskGapMinMs: 15000, @@ -86,19 +88,45 @@ export function normalizeSlots(slots, durationMinutes = 60) { return next; } +// Keeps meetings per cohort between 1 and 60. +function meetingCount(value, fallback) { + const n = Number(value); + const base = Number.isFinite(n) && n >= 1 ? n : Number(fallback) || 30; + return Math.min(Math.max(Math.round(base), 1), 60); +} + +// Each extra schedule needs a name, a day other than Sunday, and its own meeting count. +function normalizeCohortSchedules(rows, fallbackCount) { + const next = []; + const seen = new Set(); + for (const row of rows || []) { + const name = String(row?.name || '').trim(); + const weekdays = [...new Set((row?.weekdays || []).map(Number))] + .filter((day) => day >= 1 && day <= 6) + .sort((a, b) => a - b); + if (!name) throw Boom.badRequest('Each extra schedule needs a name.'); + if (!weekdays.length) throw Boom.badRequest(`${name} needs a day other than Sunday. Sunday is always skipped.`); + let id = String(row?.id || '').trim(); + if (!id || seen.has(id)) id = crypto.randomUUID(); + seen.add(id); + next.push({ id, name, weekdays, upcomingSessionCount: meetingCount(row?.upcomingSessionCount, fallbackCount) }); + } + return next; +} + export function normalizeSettingsPayload(payload = {}) { const slotDurationMinutes = Number(payload.slotDurationMinutes ?? DEFAULT_SETTINGS.slotDurationMinutes); if (slotDurationMinutes < 15 || slotDurationMinutes > 180) throw Boom.badRequest('Slot duration must be between 15 and 180 minutes.'); const repeatIntervalDays = Number(payload.repeatIntervalDays ?? DEFAULT_SETTINGS.repeatIntervalDays); if (repeatIntervalDays < 1 || repeatIntervalDays > 30) throw Boom.badRequest('Repeat interval must be between 1 and 30 days.'); const upcomingSessionCount = Number(payload.upcomingSessionCount ?? DEFAULT_SETTINGS.upcomingSessionCount); - if (upcomingSessionCount < 1 || upcomingSessionCount > 40) throw Boom.badRequest('Upcoming session count must be between 1 and 40.'); + if (upcomingSessionCount < 1 || upcomingSessionCount > 60) throw Boom.badRequest('Upcoming session count must be between 1 and 60.'); const defaultDurationMinutes = Number(payload.defaultDurationMinutes ?? DEFAULT_SETTINGS.defaultDurationMinutes); if (defaultDurationMinutes < 15 || defaultDurationMinutes > 180) throw Boom.badRequest('Meeting duration must be between 15 and 180 minutes.'); const cohortStartWeekdays = [...new Set((payload.cohortStartWeekdays ?? DEFAULT_SETTINGS.cohortStartWeekdays).map(Number))] - .filter((day) => day >= 0 && day <= 6) + .filter((day) => day >= 1 && day <= 6) .sort((a, b) => a - b); - if (!cohortStartWeekdays.length) throw Boom.badRequest('Select at least one weekday for cohort start dates.'); + if (!cohortStartWeekdays.length) throw Boom.badRequest('Select at least one weekday other than Sunday for cohort start dates.'); return { slots: normalizeSlots(payload.slots ?? DEFAULT_SETTINGS.slots, slotDurationMinutes), slotDurationMinutes, @@ -117,6 +145,7 @@ export function normalizeSettingsPayload(payload = {}) { matchingPriority: cleanStrings(payload.matchingPriority ?? DEFAULT_SETTINGS.matchingPriority, 'matching priority'), matchingCron: String(payload.matchingCron || DEFAULT_SETTINGS.matchingCron).trim() || DEFAULT_SETTINGS.matchingCron, piiRetentionDays: Math.min(Math.max(Number(payload.piiRetentionDays ?? DEFAULT_SETTINGS.piiRetentionDays) || 365, 1), 3650), + cohortSchedules: normalizeCohortSchedules(payload.cohortSchedules ?? DEFAULT_SETTINGS.cohortSchedules, upcomingSessionCount), }; } @@ -175,6 +204,12 @@ export function toSettingsDto(settings) { matchingPriority: settings.matchingPriority?.length ? settings.matchingPriority : DEFAULT_SETTINGS.matchingPriority, matchingCron: settings.matchingCron || DEFAULT_SETTINGS.matchingCron, piiRetentionDays: settings.piiRetentionDays || DEFAULT_SETTINGS.piiRetentionDays, + cohortSchedules: (settings.cohortSchedules || []).map((row) => ({ + id: row.id, + name: row.name, + weekdays: row.weekdays || [], + upcomingSessionCount: meetingCount(row.upcomingSessionCount, settings.upcomingSessionCount || DEFAULT_SETTINGS.upcomingSessionCount), + })), }; } diff --git a/backend/src/utils/serialize.js b/backend/src/utils/serialize.js index cd1e511..6f8fc38 100644 --- a/backend/src/utils/serialize.js +++ b/backend/src/utils/serialize.js @@ -1,4 +1,5 @@ import { readOnboardedPii } from '../services/cryptoPii.js'; +import { toYmd, weekdayOf } from '../services/schedule.js'; export function userLanguages(user) { const fromArray = (user?.languages || []).map((item) => String(item || '').trim()).filter(Boolean); @@ -208,7 +209,7 @@ export function toOnboardedPatientDto(item) { ? { id: String(item.assignedEducator) } : null, cohort: item.cohort && typeof item.cohort === 'object' - ? { id: String(item.cohort._id), name: item.cohort.name, startDate: item.cohort.startDate, slot: item.cohort.slot, language: item.cohort.language || '' } + ? { id: String(item.cohort._id), name: item.cohort.name, startDate: toYmd(item.cohort.startDate), slot: item.cohort.slot, language: item.cohort.language || '' } : item.cohort ? { id: String(item.cohort) } : null, @@ -242,23 +243,17 @@ function userBrief(user) { return { id: String(user) }; } -function weekdayFromDate(value) { - if (!value) return null; - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return null; - return date.getDay(); -} - export function toCohortDto(cohort) { if (!cohort) return null; const patients = Array.isArray(cohort.patients) ? cohort.patients : []; return { id: String(cohort._id), name: cohort.name, - startDate: cohort.startDate, - weekday: weekdayFromDate(cohort.startDate), + startDate: toYmd(cohort.startDate), + weekday: cohort.startDate ? weekdayOf(toYmd(cohort.startDate)) : null, slot: cohort.slot || '', language: cohort.language || '', + scheduleId: cohort.scheduleId || '', diseaseType: cohort.diseaseType || '', status: cohort.status || 'active', needsReassignment: Boolean(cohort.needsReassignment), @@ -355,6 +350,8 @@ export function toCohortSessionDto(session) { eq5d3l: record.assessment?.eq5d3l || '', sgrq: record.assessment?.sgrq || '', sitToStand: record.assessment?.sitToStand || '', + rpeRest: record.assessment?.rpeRest || '', + rpeActivity: record.assessment?.rpeActivity || '', o2Regular: record.assessment?.o2Regular || '', o2WithExercise: record.assessment?.o2WithExercise || '', }, diff --git a/frontend/public/email-logo.png b/frontend/public/email-logo.png index cb98756..50c5066 100644 Binary files a/frontend/public/email-logo.png and b/frontend/public/email-logo.png differ diff --git a/frontend/src/components/SessionVitalsModal.jsx b/frontend/src/components/SessionVitalsModal.jsx index 7d5f5cb..ad16dbd 100644 --- a/frontend/src/components/SessionVitalsModal.jsx +++ b/frontend/src/components/SessionVitalsModal.jsx @@ -14,6 +14,8 @@ function emptyAssessment() { eq5d3l: '', sgrq: '', sitToStand: '', + rpeRest: '', + rpeActivity: '', o2Regular: '', o2WithExercise: '', }; @@ -26,6 +28,8 @@ const ASSESSMENT_FIELDS = [ ['eq5d3l', 'EQ-5D'], ['sgrq', 'SGRQ'], ['sitToStand', 'Sit-to-stand'], + ['rpeRest', 'RPE at rest'], + ['rpeActivity', 'RPE with activity'], ['o2Regular', 'Oxygen at rest'], ['o2WithExercise', 'Oxygen with exercise'], ]; diff --git a/frontend/src/views/virtualpr/cohorts/form.jsx b/frontend/src/views/virtualpr/cohorts/form.jsx index 2eb1b02..28f70c7 100644 --- a/frontend/src/views/virtualpr/cohorts/form.jsx +++ b/frontend/src/views/virtualpr/cohorts/form.jsx @@ -14,6 +14,7 @@ const empty = { virtualPrt: '', language: '', diseaseType: '', + scheduleId: '', patients: [], }; @@ -23,6 +24,22 @@ function prtLanguages(prt) { return prt.language ? [prt.language] : []; } +// Keeps the date if it fits the schedule, otherwise moves it to the next allowed day. +function nextAllowedDate(startDate, days) { + const allowed = new Set((days || []).map(Number).filter((day) => day > 0 && day <= 6)); + if (!startDate) return ''; + const cursor = new Date(`${String(startDate).slice(0, 10)}T00:00:00`); + if (Number.isNaN(cursor.getTime()) || !allowed.size) return ''; + for (let i = 0; i < 14; i += 1) { + if (allowed.has(cursor.getDay())) { + const pad = (n) => String(n).padStart(2, '0'); + return `${cursor.getFullYear()}-${pad(cursor.getMonth() + 1)}-${pad(cursor.getDate())}`; + } + cursor.setDate(cursor.getDate() + 1); + } + return ''; +} + export default function CohortForm() { const { id } = useParams(); const isEdit = Boolean(id); @@ -38,7 +55,10 @@ export default function CohortForm() { const [saving, setSaving] = useState(false); const { settings } = useSettings(); const startWeekdays = settings?.cohortStartWeekdays || [1, 2]; - const sessionCount = settings?.upcomingSessionCount || 30; + const schedules = settings?.cohortSchedules || []; + const chosenSchedule = schedules.find((item) => item.id === form.scheduleId); + const allowedDays = (chosenSchedule?.weekdays?.length ? chosenSchedule.weekdays.map(Number) : startWeekdays).filter((day) => day !== 0); + const sessionCount = chosenSchedule?.upcomingSessionCount || settings?.upcomingSessionCount || 30; useEffect(() => { api('/api/users/virtual-prts').then((d) => setPrts(d.items || [])).catch(() => {}); @@ -55,8 +75,10 @@ export default function CohortForm() { }, [patientSearch]); useEffect(() => { - if (!isEdit) return; + if (!isEdit) return undefined; + let active = true; api(`/api/cohorts/${id}`).then((c) => { + if (!active) return; setForm({ name: c.name || '', startDate: c.startDate ? String(c.startDate).slice(0, 10) : '', @@ -64,12 +86,26 @@ export default function CohortForm() { virtualPrt: c.virtualPrt?.id || '', language: c.language || '', diseaseType: c.diseaseType || '', + scheduleId: c.scheduleId || '', patients: (c.patients || []).map((p) => p.id), }); setSelectedDetails((c.patients || []).map((p) => ({ id: p.id, name: p.name, mobile: p.mobile, whatsappOptOut: p.whatsappOptOut }))); setSessionSummary({ completed: c.sessionsCompleted || 0, planned: c.sessionsPlanned || 0 }); - }).catch((err) => setError(err.message)); - api(`/api/cohorts/${id}/sessions?count=${sessionCount}`).then((d) => setSessions(d.items || [])).catch(() => {}); + }).catch((err) => { + if (active) setError(err.message); + }); + return () => { active = false; }; + }, [id, isEdit]); + + useEffect(() => { + if (!isEdit) return undefined; + let active = true; + api(`/api/cohorts/${id}/sessions?count=${sessionCount}`).then((d) => { + if (active) setSessions(d.items || []); + }).catch(() => { + if (active) setSessions([]); + }); + return () => { active = false; }; }, [id, isEdit, sessionCount]); useEffect(() => { @@ -141,12 +177,12 @@ export default function CohortForm() { {error ?
{error}
: null}

- Start date must be {formatWeekdays(startWeekdays) || 'a configured weekday'} + {chosenSchedule + ? `This schedule meets on ${formatWeekdays(allowedDays)} and plans ${chosenSchedule.upcomingSessionCount || sessionCount} meetings.` + : `Start date must be ${formatWeekdays(startWeekdays) || 'a configured weekday'}. Meetings then repeat ${formatInterval(settings?.repeatIntervalDays)}.`} {settings?.skipHolidays !== false || settings?.skipPrtLeave !== false - ? `, and not fall on ${[settings?.skipHolidays !== false ? 'a holiday' : null, settings?.skipPrtLeave !== false ? 'Virtual PRT leave' : null].filter(Boolean).join(' or ')}` - : ''}. - Meetings then repeat {formatInterval(settings?.repeatIntervalDays)} - {settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}. + ? ` Skipped ${[settings?.skipHolidays !== false ? 'holidays' : null, settings?.skipPrtLeave !== false ? 'Virtual PRT leave' : null].filter(Boolean).join(' and ')} do not count as a meeting.` + : ''}

@@ -155,14 +191,36 @@ export default function CohortForm() {
Auto-created as timeslot - language weekday.
+ + Schedule + + Start date setForm((f) => ({ ...f, startDate }))} required - filterDate={(date) => startWeekdays.includes(date.getDay())} - placeholder={formatWeekdays(startWeekdays) || 'Select date'} + filterDate={(date) => date.getDay() !== 0 && allowedDays.includes(date.getDay())} + placeholder={formatWeekdays(allowedDays) || 'Select date'} /> @@ -236,7 +294,9 @@ export default function CohortForm() {
Upcoming sessions

- Repeats {formatInterval(settings?.repeatIntervalDays)} from the start date + {chosenSchedule + ? `Meets on ${formatWeekdays(allowedDays)} for ${chosenSchedule.upcomingSessionCount || sessionCount} meetings` + : `Repeats ${formatInterval(settings?.repeatIntervalDays)} from the start date`} {settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}.

diff --git a/frontend/src/views/virtualpr/matching/index.jsx b/frontend/src/views/virtualpr/matching/index.jsx index ac975e5..38110c5 100644 --- a/frontend/src/views/virtualpr/matching/index.jsx +++ b/frontend/src/views/virtualpr/matching/index.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Button, Card, CardBody, Col, Container, Row, Table } from 'react-bootstrap'; import { Link } from 'react-router'; import PageBreadcrumb from '@/components/PageBreadcrumb'; -import { WEEKDAY_LABELS } from '@/hooks/useSettings'; +import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings'; import { api } from '@/lib/api'; function weekdayLabel(weekday) { @@ -34,6 +34,8 @@ export default function MatchingPage() { const [info, setInfo] = useState(''); const [notes, setNotes] = useState([]); const [busy, setBusy] = useState(''); + const { settings } = useSettings(); + const schedules = settings?.cohortSchedules || []; const loadEmptySlots = async (items) => { const ids = [...new Set((items || []).map((cohort) => cohort.virtualPrt?.id).filter(Boolean))]; @@ -158,6 +160,24 @@ export default function MatchingPage() {
+
{(cohort.patients || []).map((patient) => (
diff --git a/frontend/src/views/virtualpr/onboarding/index.jsx b/frontend/src/views/virtualpr/onboarding/index.jsx index 2a5e340..b2df15b 100644 --- a/frontend/src/views/virtualpr/onboarding/index.jsx +++ b/frontend/src/views/virtualpr/onboarding/index.jsx @@ -217,8 +217,29 @@ export default function OnboardingPage() {
{ e.preventDefault(); setPage(0); setQuery(search); }}>
- setSearch(e.target.value)} /> + { + const value = e.target.value; + setSearch(value); + if (!value.trim()) { + setPage(0); + setQuery(''); + } + }} /> + {search ? ( + + ) : null}