1. Cohort sessions can be saved with different schedule types.

2. Patient search is fixed.
3. Patient export now includes session attendance.
4. Updating a cohort time, slot, or type updates its sessions.
5. Maximum session count is increased.
6.Global Setting updated added new config of dynamich schedule
7.Capture at 15 and 30 seesion some diff vitals
This commit is contained in:
Abhijit 2026-10-15 15:24:06 +05:30
parent c878aef1d4
commit de373aaba3
21 changed files with 630 additions and 70 deletions

View File

@ -6,6 +6,7 @@ const cohortSchema = new mongoose.Schema(
startDate: { type: Date, required: true }, startDate: { type: Date, required: true },
slot: { type: String, required: true, trim: true }, slot: { type: String, required: true, trim: true },
language: { type: String, default: '', 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 }, diseaseType: { type: String, default: '', trim: true },
status: { status: {
type: String, type: String,

View File

@ -17,6 +17,8 @@ const assessmentSchema = new mongoose.Schema(
eq5d3l: { type: String, default: '', trim: true }, eq5d3l: { type: String, default: '', trim: true },
sgrq: { type: String, default: '', trim: true }, sgrq: { type: String, default: '', trim: true },
sitToStand: { 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 }, o2Regular: { type: String, default: '', trim: true },
o2WithExercise: { type: String, default: '', trim: true }, o2WithExercise: { type: String, default: '', trim: true },
}, },

View File

@ -50,6 +50,15 @@ const settingsSchema = new mongoose.Schema(
matchingPriority: { type: [String], default: ['language', 'slot', 'weekday', 'disease'] }, matchingPriority: { type: [String], default: ['language', 'slot', 'weekday', 'disease'] },
matchingCron: { type: String, default: '0 7 * * 6' }, matchingCron: { type: String, default: '0 7 * * 6' },
piiRetentionDays: { type: Number, default: 365 }, 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' }], whatsappAdminIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
whatsappReminderRules: { type: [reminderRuleSchema], default: [] }, whatsappReminderRules: { type: [reminderRuleSchema], default: [] },
whatsappTaskGapMinMs: { type: Number, default: 15000 }, whatsappTaskGapMinMs: { type: Number, default: 15000 },

View File

@ -6,10 +6,10 @@ import { Role } from '../models/Role.js';
import { User } from '../models/User.js'; import { User } from '../models/User.js';
import { OnboardedPatient } from '../models/OnboardedPatient.js'; import { OnboardedPatient } from '../models/OnboardedPatient.js';
import { toCohortDto, userLanguages } from '../utils/serialize.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 { releaseCohortPatients, syncCohortPatients } from '../services/cohorts.js';
import { activateManualCohort } from '../services/matching.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 { CohortSession } from '../models/CohortSession.js';
import { assertConfiguredSlot, getAppSettings } from '../services/settings.js'; import { assertConfiguredSlot, getAppSettings } from '../services/settings.js';
import { toCohortSessionDto } from '../utils/serialize.js'; import { toCohortSessionDto } from '../utils/serialize.js';
@ -25,8 +25,18 @@ const cohortPayload = Joi.object({
diseaseType: Joi.string().trim().allow('', null), diseaseType: Joi.string().trim().allow('', null),
patients: Joi.array().items(objectId).default([]), patients: Joi.array().items(objectId).default([]),
durationMinutes: Joi.number().min(15).max(180).optional(), 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) { async function assertVirtualPrt(userId) {
const user = await User.findById(userId).populate('role'); const user = await User.findById(userId).populate('role');
if (!user || !user.isActive) throw Boom.badRequest('Invalid Virtual PRT'); if (!user || !user.isActive) throw Boom.badRequest('Invalid Virtual PRT');
@ -113,6 +123,7 @@ export function cohortRoutes(server) {
}, },
handler: async (request) => { handler: async (request) => {
const { startDate, slot, virtualPrt, patients } = request.payload; const { startDate, slot, virtualPrt, patients } = request.payload;
const scheduleId = String(request.payload.scheduleId || '').trim();
const settings = await getAppSettings(); const settings = await getAppSettings();
const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60; const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60;
await assertConfiguredSlot(slot); await assertConfiguredSlot(slot);
@ -126,7 +137,7 @@ export function cohortRoutes(server) {
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`); throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
} }
try { try {
await assertCohortStartDate(startDate, virtualPrt, language); await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
} catch (err) { } catch (err) {
throw Boom.badRequest(err.message); throw Boom.badRequest(err.message);
} }
@ -136,6 +147,7 @@ export function cohortRoutes(server) {
startDate: ymdToDate(startDate), startDate: ymdToDate(startDate),
slot, slot,
language, language,
scheduleId,
diseaseType: request.payload.diseaseType || '', diseaseType: request.payload.diseaseType || '',
status: 'active', status: 'active',
virtualPrt, virtualPrt,
@ -168,14 +180,15 @@ export function cohortRoutes(server) {
pre: [{ method: requirePermission('cohorts', 'view') }], pre: [{ method: requirePermission('cohorts', 'view') }],
validate: { validate: {
params: Joi.object({ id: objectId.required() }), 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) => { handler: async (request) => {
const cohort = await findCohort(request); const cohort = await findCohort(request);
const stored = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 }); const stored = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1 });
if (stored.length) return { items: stored.map(toCohortSessionDto) }; 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' })) }; return { items: dates.map((date) => ({ date, slot: cohort.slot, status: 'scheduled' })) };
}, },
}); });
@ -196,7 +209,14 @@ export function cohortRoutes(server) {
const previousName = cohort.name; const previousName = cohort.name;
const previousPrtId = String(cohort.virtualPrt?._id || cohort.virtualPrt || ''); const previousPrtId = String(cohort.virtualPrt?._id || cohort.virtualPrt || '');
const hadGroup = Boolean(cohort.whatsappGroupJid); 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 { 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 settings = await getAppSettings();
const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60; const durationMinutes = request.payload.durationMinutes || settings.defaultDurationMinutes || 60;
await assertConfiguredSlot(slot); await assertConfiguredSlot(slot);
@ -210,7 +230,7 @@ export function cohortRoutes(server) {
throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`); throw Boom.badRequest(`A cohort can have at most ${settings.maxPatientsPerCohort || 10} patients.`);
} }
try { try {
await assertCohortStartDate(startDate, virtualPrt, language); await assertCohortStartDate(startDate, virtualPrt, language, await chosenWeekdays(scheduleId));
} catch (err) { } catch (err) {
throw Boom.badRequest(err.message); throw Boom.badRequest(err.message);
} }
@ -218,6 +238,7 @@ export function cohortRoutes(server) {
cohort.startDate = ymdToDate(startDate); cohort.startDate = ymdToDate(startDate);
cohort.slot = slot; cohort.slot = slot;
cohort.language = language; cohort.language = language;
cohort.scheduleId = scheduleId;
cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || ''; cohort.diseaseType = request.payload.diseaseType || cohort.diseaseType || '';
cohort.virtualPrt = virtualPrt; cohort.virtualPrt = virtualPrt;
cohort.patients = patients; cohort.patients = patients;
@ -229,7 +250,9 @@ export function cohortRoutes(server) {
await onCohortUpdated(cohort._id, { previousName, previousPrtId, hadGroup }).catch((err) => { await onCohortUpdated(cohort._id, { previousName, previousPrtId, hadGroup }).catch((err) => {
console.error(`[whatsapp] cohort update failed: ${err?.message || 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) const ready = await Cohort.findById(cohort._id)
.populate('virtualPrt', 'firstName lastName email') .populate('virtualPrt', 'firstName lastName email')
.populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' }); .populate({ path: 'patients', match: { reviewStatus: 'confirmed' }, select: 'name mobile whatsappOptOut' });

View File

@ -19,6 +19,7 @@ import {
resolveReassignment, resolveReassignment,
retargetProposal, retargetProposal,
runMatching, runMatching,
setProposalSchedule,
} from '../services/matching.js'; } from '../services/matching.js';
const objectId = Joi.string().hex().length(24); 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({ server.route({
method: 'POST', method: 'POST',
path: '/api/matching/proposals/{id}/confirm', path: '/api/matching/proposals/{id}/confirm',

View File

@ -262,10 +262,11 @@ export function onboardingRoutes(server) {
const searchFilter = {}; const searchFilter = {};
if (search) { if (search) {
const term = String(search).trim(); const term = String(search).trim();
const hashes = [hashMobile(term), hashName(term)].filter(Boolean); const mobileHash = hashMobile(term);
const nameHash = hashName(term);
searchFilter.$or = []; searchFilter.$or = [];
if (hashes[0]) searchFilter.$or.push({ mobileHash: hashes[0] }); if (mobileHash) searchFilter.$or.push({ mobileHash });
if (hashes[1]) searchFilter.$or.push({ nameHash: hashes[1] }); if (nameHash) searchFilter.$or.push({ nameHash });
if (!searchFilter.$or.length) searchFilter.$or.push({ _id: null }); if (!searchFilter.$or.length) searchFilter.$or.push({ _id: null });
} }
const filter = mergeFilters(await onboardingScopeFilter(request), extra, searchFilter); const filter = mergeFilters(await onboardingScopeFilter(request), extra, searchFilter);

View File

@ -179,6 +179,8 @@ export function programSessionRoutes(server) {
eq5d3l: Joi.string().allow('', null), eq5d3l: Joi.string().allow('', null),
sgrq: Joi.string().allow('', null), sgrq: Joi.string().allow('', null),
sitToStand: Joi.string().allow('', null), sitToStand: Joi.string().allow('', null),
rpeRest: Joi.string().allow('', null),
rpeActivity: Joi.string().allow('', null),
o2Regular: Joi.string().allow('', null), o2Regular: Joi.string().allow('', null),
o2WithExercise: Joi.string().allow('', null), o2WithExercise: Joi.string().allow('', null),
}), }),

View File

@ -29,6 +29,12 @@ function toCsv(rows) {
return rows.map((row) => row.map(csvEscape).join(',')).join('\n'); 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) { export function reportRoutes(server) {
server.route({ server.route({
method: 'GET', method: 'GET',
@ -195,17 +201,35 @@ export function reportRoutes(server) {
rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items]; rows = [['PRT', 'Used', 'Max', 'Idle', 'Sessions', 'Cancelled'], ...items];
} else { } else {
const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined }); const patients = await listOnboardedForReports({ virtualPrtId: scopedPrt(request), diseaseType: request.query.diseaseType || undefined });
const stats = await patientProgramStats(patients.map((item) => item._id)); const cohortIds = [...new Set(patients.map((patient) => patient.cohort?._id).filter(Boolean))];
rows = [['Name', 'Mobile', 'Disease', 'Cohort', 'PRT', 'Attendance'], ...patients.map((patient) => { const sessions = cohortIds.length
const progress = stats.get(String(patient._id)) || { attended: 0, total: 0 }; ? 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 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 [ return [
pii.name, pii.name,
pii.mobile, pii.mobile,
patient.diseaseType || '', patient.diseaseType || '',
patient.cohort?.name || '', patient.cohort?.name || '',
patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '', patient.assignedEducator ? `${patient.assignedEducator.firstName} ${patient.assignedEducator.lastName}` : '',
`${progress.attended}/${progress.total}`, patient.cohort?.startDate ? toYmd(patient.cohort.startDate) : '',
...marks,
]; ];
})]; })];
} }

View File

@ -1,6 +1,7 @@
import Joi from 'joi'; import Joi from 'joi';
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
import { credentials } from '../plugins/auth.js'; import { credentials } from '../plugins/auth.js';
import { Cohort } from '../models/Cohort.js';
import { getAppSettings, normalizeSettingsPayload, toSettingsDto } from '../services/settings.js'; import { getAppSettings, normalizeSettingsPayload, toSettingsDto } from '../services/settings.js';
function requireSuperAdmin(request, h) { function requireSuperAdmin(request, h) {
@ -17,7 +18,7 @@ const settingsPayload = Joi.object({
slotDurationMinutes: Joi.number().min(15).max(180).required(), slotDurationMinutes: Joi.number().min(15).max(180).required(),
cohortStartWeekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(), cohortStartWeekdays: Joi.array().items(Joi.number().integer().min(0).max(6)).min(1).required(),
repeatIntervalDays: Joi.number().integer().min(1).max(30).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(), defaultDurationMinutes: Joi.number().min(15).max(180).required(),
skipHolidays: Joi.boolean().required(), skipHolidays: Joi.boolean().required(),
skipPrtLeave: 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(), matchingPriority: Joi.array().items(Joi.string().trim().min(1)).min(1).required(),
matchingCron: Joi.string().trim().min(1).required(), matchingCron: Joi.string().trim().min(1).required(),
piiRetentionDays: Joi.number().integer().min(1).max(3650).optional(), 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) { export function settingsRoutes(server) {
@ -48,7 +55,15 @@ export function settingsRoutes(server) {
}, },
handler: async (request) => { handler: async (request) => {
const settings = await getAppSettings(); 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(); await settings.save();
return toSettingsDto(settings); return toSettingsDto(settings);
}, },

View File

@ -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, { export async function updateCalendarEvent(user, eventId, {
title, title,
start, start,
end, end,
timeZone, timeZone,
description = '', description = '',
recurrence = null,
}) { }) {
if (!eventId) return null; if (!eventId) return null;
if (!user.google?.refreshToken && !user.google?.accessToken) { if (!user.google?.refreshToken && !user.google?.accessToken) {
@ -247,16 +249,19 @@ export async function updateCalendarEvent(user, eventId, {
} }
const auth = oauthClientFor(user); const auth = oauthClientFor(user);
const calendar = google.calendar({ version: 'v3', auth }); 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 { try {
const { data } = await calendar.events.patch({ const { data } = await calendar.events.patch({
calendarId: 'primary', calendarId: 'primary',
eventId, eventId,
requestBody: { sendUpdates: 'none',
summary: title, requestBody,
description: description || undefined,
start: { dateTime: start, timeZone },
end: { dateTime: end, timeZone },
},
}); });
return { return {
id: data.id, id: data.id,

View File

@ -7,7 +7,7 @@ import { User } from '../models/User.js';
import { addPatientToCohort, removePatientFromCohort, syncCohortPatients } from './cohorts.js'; import { addPatientToCohort, removePatientFromCohort, syncCohortPatients } from './cohorts.js';
import { notifyCohortConfirmed, notifyReassignment } from './notify.js'; import { notifyCohortConfirmed, notifyReassignment } from './notify.js';
import { persistCohortSessions } from './programSessions.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 { assertConfiguredSlot, getAppSettings, slotLabel, WEEKDAY_LABELS } from './settings.js';
import { readOnboardedPii } from './cryptoPii.js'; import { readOnboardedPii } from './cryptoPii.js';
import { toCohortDto, userLanguages } from '../utils/serialize.js'; import { toCohortDto, userLanguages } from '../utils/serialize.js';
@ -425,12 +425,45 @@ async function fieldNoticesForCohort(cohort) {
return [...seen.values()]; 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) { export async function confirmCohort(cohortId) {
const cohort = await Cohort.findById(cohortId).populate('virtualPrt').populate('patients'); const cohort = await Cohort.findById(cohortId).populate('virtualPrt').populate('patients');
if (!cohort) throw Boom.notFound('Cohort not found'); if (!cohort) throw Boom.notFound('Cohort not found');
if (cohort.status !== 'proposed') throw Boom.badRequest('Only proposed cohorts can be confirmed from matching.'); if (cohort.status !== 'proposed') throw Boom.badRequest('Only proposed cohorts can be confirmed from matching.');
const startYmd = toYmd(cohort.startDate); 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.startDate = ymdToDate(nextStartDateForWeekday(weekdayOf(startYmd)));
cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: toYmd(cohort.startDate) }); cohort.name = buildCohortName({ slot: cohort.slot, language: cohort.language, startDate: toYmd(cohort.startDate) });
} }

View File

@ -15,6 +15,9 @@ import {
toYmd, toYmd,
todayYmd, todayYmd,
upcomingSessionDates, upcomingSessionDates,
scheduleSessionCount,
scheduleWeekdays,
weekdayOf,
} from './schedule.js'; } from './schedule.js';
import { getAppSettings } from './settings.js'; import { getAppSettings } from './settings.js';
import { toCohortSessionDto } from '../utils/serialize.js'; import { toCohortSessionDto } from '../utils/serialize.js';
@ -60,9 +63,12 @@ function recordsFor(ids) {
return ids.map((patient) => ({ patient, attended: false, pre: {}, post: {} })); 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) { export async function persistCohortSessions(cohort) {
const settings = await getAppSettings(); 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 existing = await CohortSession.find({ cohort: cohort._id }).sort({ index: 1, date: 1 });
const active = existing.filter((session) => session.status !== 'cancelled'); const active = existing.filter((session) => session.status !== 'cancelled');
const patients = patientIds(cohort); const patients = patientIds(cohort);
@ -73,6 +79,7 @@ export async function persistCohortSessions(cohort) {
cohort.virtualPrt?._id || cohort.virtualPrt, cohort.virtualPrt?._id || cohort.virtualPrt,
needed, needed,
cohort.language, cohort.language,
weekdays,
); );
if (!dates.length) return []; if (!dates.length) return [];
await CohortSession.insertMany(dates.map((date, index) => ({ 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 }); 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) { export async function ensureSessionRecords(cohort) {
const patients = patientIds(cohort).map(String); const patients = patientIds(cohort).map(String);
if (!patients.length) return; 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) { export async function nextAppendDate(cohort) {
const weekdays = await scheduleWeekdays(cohort.scheduleId);
if (weekdays) return nextWeekdayAppendDate(cohort, weekdays);
const settings = await getAppSettings(); const settings = await getAppSettings();
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2);
const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 }); const last = await CohortSession.findOne({ cohort: cohort._id }).sort({ date: -1, index: -1 });
@ -133,9 +270,10 @@ export async function nextAppendDate(cohort) {
: new Set(); : new Set();
const prtId = cohort.virtualPrt?._id || cohort.virtualPrt; const prtId = cohort.virtualPrt?._id || cohort.virtualPrt;
for (let guard = 0; guard < 400; guard += 1) { for (let guard = 0; guard < 400; guard += 1) {
const sundayBlocked = weekdayOf(cursor) === 0;
const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor); const holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor);
const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(prtId, 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); cursor = addDays(cursor, interval);
} }
return cursor; return cursor;
@ -273,6 +411,7 @@ export async function ensureMeetForCohort(cohort) {
} }
const settings = await getAppSettings(); const settings = await getAppSettings();
const weekdays = await scheduleWeekdays(cohort.scheduleId);
const first = sessions[0]; const first = sessions[0];
const last = sessions[sessions.length - 1]; const last = sessions[sessions.length - 1];
const times = slotDateTimes(first.date, first.slot || cohort.slot, cohort.durationMinutes || settings.defaultDurationMinutes || 60); 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, startTime: times.startTime,
timeZone: env.APP_TIMEZONE, timeZone: env.APP_TIMEZONE,
includeDates: sessions.map((session) => session.date), includeDates: sessions.map((session) => session.date),
weekdays,
}), }),
}); });
await applyMeetToSessions(sessions, event, first.slot || cohort.slot); 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 ?? '', eq5d3l: incoming.assessment.eq5d3l ?? previous.eq5d3l ?? '',
sgrq: incoming.assessment.sgrq ?? previous.sgrq ?? '', sgrq: incoming.assessment.sgrq ?? previous.sgrq ?? '',
sitToStand: incoming.assessment.sitToStand ?? previous.sitToStand ?? '', sitToStand: incoming.assessment.sitToStand ?? previous.sitToStand ?? '',
rpeRest: incoming.assessment.rpeRest ?? previous.rpeRest ?? '',
rpeActivity: incoming.assessment.rpeActivity ?? previous.rpeActivity ?? '',
o2Regular: incoming.assessment.o2Regular ?? previous.o2Regular ?? '', o2Regular: incoming.assessment.o2Regular ?? previous.o2Regular ?? '',
o2WithExercise: incoming.assessment.o2WithExercise ?? previous.o2WithExercise ?? '', o2WithExercise: incoming.assessment.o2WithExercise ?? previous.o2WithExercise ?? '',
}; };
@ -575,7 +717,7 @@ export async function listOnboardedForReports({ virtualPrtId, diseaseType, cohor
if (diseaseType) filter.diseaseType = diseaseType; if (diseaseType) filter.diseaseType = diseaseType;
if (cohortId) filter.cohort = cohortId; if (cohortId) filter.cohort = cohortId;
return OnboardedPatient.find(filter) 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('assignedEducator', 'firstName lastName email')
.populate('onboardedBy', 'firstName lastName email') .populate('onboardedBy', 'firstName lastName email')
.sort({ name: 1 }); .sort({ name: 1 });

View File

@ -73,9 +73,38 @@ export async function leaveCoversDate(userId, ymd) {
return Boolean(leave); 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(); 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)}.`); throw new Error(`A new cohort can only start on ${formatWeekdays(settings.cohortStartWeekdays)}.`);
} }
if (settings.skipHolidays !== false) { if (settings.skipHolidays !== false) {
@ -119,8 +148,10 @@ export function slotDateTimes(date, slot, durationMinutes = 60) {
return { startTime, endTime, start, end }; 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) { export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, language) {
if (!startYmd || !targetYmd || targetYmd < startYmd) return false; if (!startYmd || !targetYmd || targetYmd < startYmd) return false;
if (weekdayOf(targetYmd) === 0) return false;
const settings = await getAppSettings(); const settings = await getAppSettings();
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2);
const start = parseYmd(startYmd); const start = parseYmd(startYmd);
@ -135,39 +166,63 @@ export async function isCohortSessionOn(startYmd, virtualPrtId, targetYmd, langu
return true; 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 settings = await getAppSettings();
const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 40); const needed = Math.min(Math.max(Number(count) || settings.upcomingSessionCount || 30, 1), 60);
const interval = Math.max(1, Number(settings.repeatIntervalDays) || 2); 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 horizon = addDays(startYmd, needed * interval * 8);
const holidays = settings.skipHolidays !== false ? await holidayDatesBetween(startYmd, horizon, language) : new Set(); const holidays = settings.skipHolidays !== false ? await holidayDatesBetween(startYmd, horizon, language) : new Set();
const dates = []; const dates = [];
let cursor = startYmd; let cursor = startYmd;
let guard = 0; let guard = 0;
while (dates.length < needed && guard < 400) { 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 holidayBlocked = settings.skipHolidays !== false && holidays.has(cursor);
const leaveBlocked = settings.skipPrtLeave !== false && await leaveCoversDate(virtualPrtId, 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); cursor = addDays(cursor, interval);
guard += 1; guard += 1;
} }
return dates; 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 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 time = `${String(startTime || '09:00').replace(':', '')}00`.slice(0, 6);
const stamp = (ymd) => `${String(ymd).replace(/-/g, '')}T${time}`; const stamp = (ymd) => `${String(ymd).replace(/-/g, '')}T${time}`;
let cursor = firstDate; let cursor = firstDate;
let count = 0; let count = 0;
const exdates = []; const exdates = [];
while (cursor <= lastDate && count < 80) { let guard = 0;
count += 1; while (cursor <= lastDate && count < 120 && guard < 500) {
if (include.size && !include.has(cursor)) exdates.push(cursor); 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); 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) { if (exdates.length) {
lines.push(`EXDATE;TZID=${timeZone || 'Asia/Kolkata'}:${exdates.map(stamp).join(',')}`); lines.push(`EXDATE;TZID=${timeZone || 'Asia/Kolkata'}:${exdates.map(stamp).join(',')}`);
} }

View File

@ -1,4 +1,5 @@
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
import crypto from 'node:crypto';
import { Settings } from '../models/Settings.js'; import { Settings } from '../models/Settings.js';
const TIME = /^\d{2}:\d{2}$/; const TIME = /^\d{2}:\d{2}$/;
@ -29,7 +30,7 @@ export const DEFAULT_SETTINGS = {
defaultDurationMinutes: 60, defaultDurationMinutes: 60,
skipHolidays: true, skipHolidays: true,
skipPrtLeave: true, skipPrtLeave: true,
languages: ['Tamil', 'Telagu', 'Hindi', 'Malayalam', 'Bangali', 'Kannda', 'Marathi'], languages: ['Tamil', 'Telugu', 'Hindi', 'Malayalam', 'Bengali', 'Kannada', 'Marathi'],
paymentStatuses: ['Unpaid', 'Paid', 'Partial'], paymentStatuses: ['Unpaid', 'Paid', 'Partial'],
genders: ['Male', 'Female', 'Other'], genders: ['Male', 'Female', 'Other'],
diseaseTypes: ['ILD', 'OAD'], diseaseTypes: ['ILD', 'OAD'],
@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS = {
matchingPriority: ['language', 'slot', 'weekday', 'disease'], matchingPriority: ['language', 'slot', 'weekday', 'disease'],
matchingCron: '0 7 * * 6', matchingCron: '0 7 * * 6',
piiRetentionDays: 365, piiRetentionDays: 365,
cohortSchedules: [],
whatsappAdminIds: [], whatsappAdminIds: [],
whatsappReminderRules: [], whatsappReminderRules: [],
whatsappTaskGapMinMs: 15000, whatsappTaskGapMinMs: 15000,
@ -86,19 +88,45 @@ export function normalizeSlots(slots, durationMinutes = 60) {
return next; 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 = {}) { export function normalizeSettingsPayload(payload = {}) {
const slotDurationMinutes = Number(payload.slotDurationMinutes ?? DEFAULT_SETTINGS.slotDurationMinutes); 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.'); 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); 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.'); 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); 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); 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.'); 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))] 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); .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 { return {
slots: normalizeSlots(payload.slots ?? DEFAULT_SETTINGS.slots, slotDurationMinutes), slots: normalizeSlots(payload.slots ?? DEFAULT_SETTINGS.slots, slotDurationMinutes),
slotDurationMinutes, slotDurationMinutes,
@ -117,6 +145,7 @@ export function normalizeSettingsPayload(payload = {}) {
matchingPriority: cleanStrings(payload.matchingPriority ?? DEFAULT_SETTINGS.matchingPriority, 'matching priority'), matchingPriority: cleanStrings(payload.matchingPriority ?? DEFAULT_SETTINGS.matchingPriority, 'matching priority'),
matchingCron: String(payload.matchingCron || DEFAULT_SETTINGS.matchingCron).trim() || DEFAULT_SETTINGS.matchingCron, 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), 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, matchingPriority: settings.matchingPriority?.length ? settings.matchingPriority : DEFAULT_SETTINGS.matchingPriority,
matchingCron: settings.matchingCron || DEFAULT_SETTINGS.matchingCron, matchingCron: settings.matchingCron || DEFAULT_SETTINGS.matchingCron,
piiRetentionDays: settings.piiRetentionDays || DEFAULT_SETTINGS.piiRetentionDays, 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),
})),
}; };
} }

View File

@ -1,4 +1,5 @@
import { readOnboardedPii } from '../services/cryptoPii.js'; import { readOnboardedPii } from '../services/cryptoPii.js';
import { toYmd, weekdayOf } from '../services/schedule.js';
export function userLanguages(user) { export function userLanguages(user) {
const fromArray = (user?.languages || []).map((item) => String(item || '').trim()).filter(Boolean); const fromArray = (user?.languages || []).map((item) => String(item || '').trim()).filter(Boolean);
@ -208,7 +209,7 @@ export function toOnboardedPatientDto(item) {
? { id: String(item.assignedEducator) } ? { id: String(item.assignedEducator) }
: null, : null,
cohort: item.cohort && typeof item.cohort === 'object' 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 : item.cohort
? { id: String(item.cohort) } ? { id: String(item.cohort) }
: null, : null,
@ -242,23 +243,17 @@ function userBrief(user) {
return { id: String(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) { export function toCohortDto(cohort) {
if (!cohort) return null; if (!cohort) return null;
const patients = Array.isArray(cohort.patients) ? cohort.patients : []; const patients = Array.isArray(cohort.patients) ? cohort.patients : [];
return { return {
id: String(cohort._id), id: String(cohort._id),
name: cohort.name, name: cohort.name,
startDate: cohort.startDate, startDate: toYmd(cohort.startDate),
weekday: weekdayFromDate(cohort.startDate), weekday: cohort.startDate ? weekdayOf(toYmd(cohort.startDate)) : null,
slot: cohort.slot || '', slot: cohort.slot || '',
language: cohort.language || '', language: cohort.language || '',
scheduleId: cohort.scheduleId || '',
diseaseType: cohort.diseaseType || '', diseaseType: cohort.diseaseType || '',
status: cohort.status || 'active', status: cohort.status || 'active',
needsReassignment: Boolean(cohort.needsReassignment), needsReassignment: Boolean(cohort.needsReassignment),
@ -355,6 +350,8 @@ export function toCohortSessionDto(session) {
eq5d3l: record.assessment?.eq5d3l || '', eq5d3l: record.assessment?.eq5d3l || '',
sgrq: record.assessment?.sgrq || '', sgrq: record.assessment?.sgrq || '',
sitToStand: record.assessment?.sitToStand || '', sitToStand: record.assessment?.sitToStand || '',
rpeRest: record.assessment?.rpeRest || '',
rpeActivity: record.assessment?.rpeActivity || '',
o2Regular: record.assessment?.o2Regular || '', o2Regular: record.assessment?.o2Regular || '',
o2WithExercise: record.assessment?.o2WithExercise || '', o2WithExercise: record.assessment?.o2WithExercise || '',
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -14,6 +14,8 @@ function emptyAssessment() {
eq5d3l: '', eq5d3l: '',
sgrq: '', sgrq: '',
sitToStand: '', sitToStand: '',
rpeRest: '',
rpeActivity: '',
o2Regular: '', o2Regular: '',
o2WithExercise: '', o2WithExercise: '',
}; };
@ -26,6 +28,8 @@ const ASSESSMENT_FIELDS = [
['eq5d3l', 'EQ-5D'], ['eq5d3l', 'EQ-5D'],
['sgrq', 'SGRQ'], ['sgrq', 'SGRQ'],
['sitToStand', 'Sit-to-stand'], ['sitToStand', 'Sit-to-stand'],
['rpeRest', 'RPE at rest'],
['rpeActivity', 'RPE with activity'],
['o2Regular', 'Oxygen at rest'], ['o2Regular', 'Oxygen at rest'],
['o2WithExercise', 'Oxygen with exercise'], ['o2WithExercise', 'Oxygen with exercise'],
]; ];

View File

@ -14,6 +14,7 @@ const empty = {
virtualPrt: '', virtualPrt: '',
language: '', language: '',
diseaseType: '', diseaseType: '',
scheduleId: '',
patients: [], patients: [],
}; };
@ -23,6 +24,22 @@ function prtLanguages(prt) {
return prt.language ? [prt.language] : []; 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() { export default function CohortForm() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
@ -38,7 +55,10 @@ export default function CohortForm() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const { settings } = useSettings(); const { settings } = useSettings();
const startWeekdays = settings?.cohortStartWeekdays || [1, 2]; 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(() => { useEffect(() => {
api('/api/users/virtual-prts').then((d) => setPrts(d.items || [])).catch(() => {}); api('/api/users/virtual-prts').then((d) => setPrts(d.items || [])).catch(() => {});
@ -55,8 +75,10 @@ export default function CohortForm() {
}, [patientSearch]); }, [patientSearch]);
useEffect(() => { useEffect(() => {
if (!isEdit) return; if (!isEdit) return undefined;
let active = true;
api(`/api/cohorts/${id}`).then((c) => { api(`/api/cohorts/${id}`).then((c) => {
if (!active) return;
setForm({ setForm({
name: c.name || '', name: c.name || '',
startDate: c.startDate ? String(c.startDate).slice(0, 10) : '', startDate: c.startDate ? String(c.startDate).slice(0, 10) : '',
@ -64,12 +86,26 @@ export default function CohortForm() {
virtualPrt: c.virtualPrt?.id || '', virtualPrt: c.virtualPrt?.id || '',
language: c.language || '', language: c.language || '',
diseaseType: c.diseaseType || '', diseaseType: c.diseaseType || '',
scheduleId: c.scheduleId || '',
patients: (c.patients || []).map((p) => p.id), patients: (c.patients || []).map((p) => p.id),
}); });
setSelectedDetails((c.patients || []).map((p) => ({ id: p.id, name: p.name, mobile: p.mobile, whatsappOptOut: p.whatsappOptOut }))); 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 }); setSessionSummary({ completed: c.sessionsCompleted || 0, planned: c.sessionsPlanned || 0 });
}).catch((err) => setError(err.message)); }).catch((err) => {
api(`/api/cohorts/${id}/sessions?count=${sessionCount}`).then((d) => setSessions(d.items || [])).catch(() => {}); 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]); }, [id, isEdit, sessionCount]);
useEffect(() => { useEffect(() => {
@ -141,12 +177,12 @@ export default function CohortForm() {
<CardBody> <CardBody>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<p className="text-muted"> <p className="text-muted">
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 {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 ')}` ? ` Skipped ${[settings?.skipHolidays !== false ? 'holidays' : null, settings?.skipPrtLeave !== false ? 'Virtual PRT leave' : null].filter(Boolean).join(' and ')} do not count as a meeting.`
: ''}. : ''}
Meetings then repeat {formatInterval(settings?.repeatIntervalDays)}
{settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}.
</p> </p>
<form onSubmit={save}> <form onSubmit={save}>
<Row className="g-3"> <Row className="g-3">
@ -155,14 +191,36 @@ export default function CohortForm() {
<FormControl value={form.name} readOnly plaintext={false} placeholder="Created from slot, PRT language, and start day" /> <FormControl value={form.name} readOnly plaintext={false} placeholder="Created from slot, PRT language, and start day" />
<div className="form-text">Auto-created as timeslot - language weekday.</div> <div className="form-text">Auto-created as timeslot - language weekday.</div>
</Col> </Col>
<Col md={3}>
<FormLabel>Schedule</FormLabel>
<select
className="form-select"
value={form.scheduleId}
onChange={(e) => {
const scheduleId = e.target.value;
const next = schedules.find((item) => item.id === scheduleId);
const days = (next?.weekdays?.length ? next.weekdays.map(Number) : startWeekdays).filter((day) => day !== 0);
setForm((current) => ({
...current,
scheduleId,
startDate: nextAllowedDate(current.startDate, days),
}));
}}
>
<option value="">Normal (current)</option>
{schedules.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</Col>
<Col md={3}> <Col md={3}>
<FormLabel>Start date</FormLabel> <FormLabel>Start date</FormLabel>
<DateField <DateField
value={form.startDate} value={form.startDate}
onChange={(startDate) => setForm((f) => ({ ...f, startDate }))} onChange={(startDate) => setForm((f) => ({ ...f, startDate }))}
required required
filterDate={(date) => startWeekdays.includes(date.getDay())} filterDate={(date) => date.getDay() !== 0 && allowedDays.includes(date.getDay())}
placeholder={formatWeekdays(startWeekdays) || 'Select date'} placeholder={formatWeekdays(allowedDays) || 'Select date'}
/> />
</Col> </Col>
<Col md={3}> <Col md={3}>
@ -236,7 +294,9 @@ export default function CohortForm() {
<div className="mt-3"> <div className="mt-3">
<h6>Upcoming sessions</h6> <h6>Upcoming sessions</h6>
<p className="text-muted mb-2"> <p className="text-muted mb-2">
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' : ''}. {settings?.skipHolidays !== false || settings?.skipPrtLeave !== false ? ', skipping blocked days' : ''}.
</p> </p>
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Button, Card, CardBody, Col, Container, Row, Table } from 'react-bootstrap'; import { Button, Card, CardBody, Col, Container, Row, Table } from 'react-bootstrap';
import { Link } from 'react-router'; import { Link } from 'react-router';
import PageBreadcrumb from '@/components/PageBreadcrumb'; import PageBreadcrumb from '@/components/PageBreadcrumb';
import { WEEKDAY_LABELS } from '@/hooks/useSettings'; import { WEEKDAY_LABELS, useSettings } from '@/hooks/useSettings';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
function weekdayLabel(weekday) { function weekdayLabel(weekday) {
@ -34,6 +34,8 @@ export default function MatchingPage() {
const [info, setInfo] = useState(''); const [info, setInfo] = useState('');
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [busy, setBusy] = useState(''); const [busy, setBusy] = useState('');
const { settings } = useSettings();
const schedules = settings?.cohortSchedules || [];
const loadEmptySlots = async (items) => { const loadEmptySlots = async (items) => {
const ids = [...new Set((items || []).map((cohort) => cohort.virtualPrt?.id).filter(Boolean))]; const ids = [...new Set((items || []).map((cohort) => cohort.virtualPrt?.id).filter(Boolean))];
@ -158,6 +160,24 @@ export default function MatchingPage() {
</div> </div>
<Button size="sm" disabled={busy} onClick={() => act(`confirm-${cohort.id}`, () => api(`/api/matching/proposals/${cohort.id}/confirm`, { method: 'POST' }), `${cohort.name} confirmed.`)}>Confirm</Button> <Button size="sm" disabled={busy} onClick={() => act(`confirm-${cohort.id}`, () => api(`/api/matching/proposals/${cohort.id}/confirm`, { method: 'POST' }), `${cohort.name} confirmed.`)}>Confirm</Button>
</div> </div>
<select
className="form-select form-select-sm mt-2"
style={{ maxWidth: 280 }}
value={cohort.scheduleId || ''}
disabled={Boolean(busy)}
onChange={(e) => act(
`schedule-${cohort.id}`,
() => api(`/api/matching/proposals/${cohort.id}/schedule`, {
method: 'PATCH',
body: { scheduleId: e.target.value },
}),
)}
>
<option value="">Normal (current)</option>
{schedules.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<div className="small mt-2"> <div className="small mt-2">
{(cohort.patients || []).map((patient) => ( {(cohort.patients || []).map((patient) => (
<div key={patient.id} className="d-flex justify-content-between align-items-center py-1"> <div key={patient.id} className="d-flex justify-content-between align-items-center py-1">

View File

@ -217,8 +217,29 @@ export default function OnboardingPage() {
<div className="d-flex flex-wrap gap-2 align-items-center w-100"> <div className="d-flex flex-wrap gap-2 align-items-center w-100">
<form className="d-flex flex-wrap gap-2 align-items-center flex-grow-1" onSubmit={(e) => { e.preventDefault(); setPage(0); setQuery(search); }}> <form className="d-flex flex-wrap gap-2 align-items-center flex-grow-1" onSubmit={(e) => { e.preventDefault(); setPage(0); setQuery(search); }}>
<div className="app-search flex-shrink-0" style={{ width: 240 }}> <div className="app-search flex-shrink-0" style={{ width: 240 }}>
<input className="form-control" placeholder="Search name or mobile..." value={search} onChange={(e) => setSearch(e.target.value)} /> <input className="form-control" placeholder="Search name or mobile..." value={search} style={search ? { paddingRight: 32 } : undefined} onChange={(e) => {
const value = e.target.value;
setSearch(value);
if (!value.trim()) {
setPage(0);
setQuery('');
}
}} />
<LuSearch className="app-search-icon text-muted" /> <LuSearch className="app-search-icon text-muted" />
{search ? (
<button
type="button"
className="btn btn-link position-absolute top-50 translate-middle-y end-0 text-muted p-0 pe-2"
aria-label="Clear search"
onClick={() => {
setSearch('');
setPage(0);
setQuery('');
}}
>
<TbX />
</button>
) : null}
</div> </div>
<Button type="submit" variant="light">Search</Button> <Button type="submit" variant="light">Search</Button>
<select className="form-select" style={{ width: 150 }} value={list} onChange={(e) => { setPage(0); setList(e.target.value); }}> <select className="form-select" style={{ width: 150 }} value={list} onChange={(e) => { setPage(0); setList(e.target.value); }}>

View File

@ -34,6 +34,7 @@ function emptyForm() {
matchingPriority: ['language', 'slot', 'weekday', 'disease'], matchingPriority: ['language', 'slot', 'weekday', 'disease'],
matchingCron: '0 7 * * 6', matchingCron: '0 7 * * 6',
piiRetentionDays: 365, piiRetentionDays: 365,
cohortSchedules: [],
}; };
} }
@ -95,6 +96,12 @@ export default function SettingsPage() {
matchingPriority: data.matchingPriority || ['language', 'slot', 'weekday', 'disease'], matchingPriority: data.matchingPriority || ['language', 'slot', 'weekday', 'disease'],
matchingCron: data.matchingCron || '0 7 * * 6', matchingCron: data.matchingCron || '0 7 * * 6',
piiRetentionDays: data.piiRetentionDays || 365, piiRetentionDays: data.piiRetentionDays || 365,
cohortSchedules: (data.cohortSchedules || []).map((row) => ({
id: row.id || '',
name: row.name || '',
weekdays: row.weekdays || [],
upcomingSessionCount: row.upcomingSessionCount || data.upcomingSessionCount || 30,
})),
}); });
}; };
@ -157,6 +164,12 @@ export default function SettingsPage() {
genders: form.genders.map((item) => item.trim()).filter(Boolean), genders: form.genders.map((item) => item.trim()).filter(Boolean),
diseaseTypes: form.diseaseTypes.map((item) => item.trim()).filter(Boolean), diseaseTypes: form.diseaseTypes.map((item) => item.trim()).filter(Boolean),
matchingPriority: form.matchingPriority.map((item) => item.trim()).filter(Boolean), matchingPriority: form.matchingPriority.map((item) => item.trim()).filter(Boolean),
cohortSchedules: (form.cohortSchedules || []).map((row) => ({
id: row.id,
name: row.name,
weekdays: row.weekdays,
upcomingSessionCount: Number(row.upcomingSessionCount) || Number(form.upcomingSessionCount) || 30,
})),
}, },
}); });
await load(); await load();
@ -187,8 +200,9 @@ export default function SettingsPage() {
key={label} key={label}
type="checkbox" type="checkbox"
id={`weekday-${day}`} id={`weekday-${day}`}
label={label} label={day === 0 ? `${label} (always skipped)` : label}
checked={form.cohortStartWeekdays.includes(day)} checked={day !== 0 && form.cohortStartWeekdays.includes(day)}
disabled={day === 0}
onChange={() => toggleWeekday(day)} onChange={() => toggleWeekday(day)}
/> />
))} ))}
@ -211,7 +225,7 @@ export default function SettingsPage() {
<FormControl <FormControl
type="number" type="number"
min={1} min={1}
max={40} max={60}
value={form.upcomingSessionCount} value={form.upcomingSessionCount}
onChange={(e) => setField('upcomingSessionCount', e.target.value)} onChange={(e) => setField('upcomingSessionCount', e.target.value)}
required required
@ -230,6 +244,87 @@ export default function SettingsPage() {
/> />
</Col> </Col>
</Row> </Row>
<div className="mt-4">
<FormLabel>Extra schedules</FormLabel>
<p className="text-muted small mb-2">
Normal stays the Repeat every rule and the Meetings per cohort number above. Each extra schedule has its own Meetings per cohort. A skipped holiday or leave does not count as a meeting.
</p>
{(form.cohortSchedules || []).map((row, index) => (
<div key={row.id || index} className="border rounded p-3 mb-2">
<div className="d-flex gap-2 mb-2">
<FormControl
value={row.name}
placeholder="Schedule name"
onChange={(e) => setForm((current) => ({
...current,
cohortSchedules: current.cohortSchedules.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)),
}))}
required
/>
<Button
type="button"
variant="outline-danger"
size="sm"
className="btn-icon"
onClick={() => setForm((current) => ({
...current,
cohortSchedules: current.cohortSchedules.filter((_, i) => i !== index),
}))}
>
<TbTrash className="fs-lg" />
</Button>
</div>
<div className="mb-2" style={{ maxWidth: 220 }}>
<FormLabel>Meetings per cohort</FormLabel>
<FormControl
type="number"
min={1}
max={60}
value={row.upcomingSessionCount}
onChange={(e) => setForm((current) => ({
...current,
cohortSchedules: current.cohortSchedules.map((item, i) => (i === index ? { ...item, upcomingSessionCount: e.target.value } : item)),
}))}
required
/>
</div>
<div className="d-flex flex-wrap gap-3">
{WEEKDAY_LABELS.map((label, day) => (
<FormCheck
key={label}
type="checkbox"
id={`schedule-${index}-${day}`}
label={day === 0 ? `${label} (always skipped)` : label}
checked={day !== 0 && (row.weekdays || []).includes(day)}
disabled={day === 0}
onChange={() => setForm((current) => ({
...current,
cohortSchedules: current.cohortSchedules.map((item, i) => {
if (i !== index) return item;
const has = (item.weekdays || []).includes(day);
const weekdays = has
? item.weekdays.filter((value) => value !== day)
: [...(item.weekdays || []), day].sort((a, b) => a - b);
return { ...item, weekdays };
}),
}))}
/>
))}
</div>
</div>
))}
<Button
type="button"
variant="light"
size="sm"
onClick={() => setForm((current) => ({
...current,
cohortSchedules: [...(current.cohortSchedules || []), { id: '', name: '', weekdays: [1], upcomingSessionCount: Number(current.upcomingSessionCount) || 30 }],
}))}
>
<TbPlus className="me-1" /> Add schedule
</Button>
</div>
<div className="mt-3"> <div className="mt-3">
<FormCheck <FormCheck
type="switch" type="switch"