diff --git a/src/testUtils.js b/src/testUtils.js index 59f817af77..79fd496c35 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; import faker from '@faker-js/faker'; -import { REPORT_STATUSES } from '@ttahub/common'; +import { REPORT_STATUSES, TRAINING_REPORT_STATUSES } from '@ttahub/common'; import { AUTOMATIC_CREATION } from './constants'; import { ActivityReport, @@ -11,7 +11,8 @@ import { Region, GoalTemplate, Goal, - // GrantGoal, + EventReportPilot, + SessionReportPilot, } from './models'; import { auditLogger } from './logger'; @@ -295,3 +296,205 @@ export async function createGoalTemplate({ creationMethod, }); } + +export function mockTrainingReportData(data) { + return { + goal: 'The goal is that recipients have well written, fundable grant applications that reflect their community needs, includes data, and data informed decisions. The Regional Office and TTA have identified that 75% of our recipients will be completing a baseline application within the next 18 months. Staggering the supports and training for the grant application where the applications do sooner have different supports vs. programs who have 12-18 months to implement best practices when it comes to the grant application.\n', + region: 0, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + vision: '\nThe series will have the following five sessions for recipients who have 6-12 months to complete their application.\n1. Grant Application Process & Nuts and Bolts of Strategic Planning \n2. Development of the Community & Self-Assessment\n3. Program and School Readiness Goals\n4. Education & Health Services\n5. Financial Essentials to Create a Fundable Application\n\nWe selected the target population as all below since they all will be discussed throughout the grant application process; programs should take that all into consideration when writing a baseline grant application.', + creator: faker.internet.email(), + endDate: '12/11/2023', + eventId: `R08-TR-23-${faker.datatype.number({ min: 1000, max: 9999 })}`, + reasons: [ + 'Full Enrollment', + 'Ongoing Quality Improvement', + 'School Readiness Goals', + ], + audience: 'Recipients', + 'IST Name:': faker.hacker.noun(), + eventName: 'Baseline Grant Application Nuts and Bolts', + pageState: { + 1: 'Complete', + 2: 'Complete', + 3: 'In progress', + }, + startDate: '10/10/2023', + pocComplete: false, + trainingType: 'Series', + pocCompleteId: '', + 'Sheet Name': '', + eventOrganizer: 'Regional PD Event (with National Centers)', + pocCompleteDate: '', + targetPopulations: [ + 'Affected by Child Welfare Involvement', + 'Affected by Disaster', + 'Affected by Substance Use', + 'Children Experiencing Homelessness', + 'Children/Families affected by systemic discrimination/bias/exclusion.', + 'Children/Families affected by traumatic events (select the other reasons for child welfare, disaster, substance use or homelessness)', + 'Children in Migrant and Seasonal Families', + 'Children with Disabilities', + 'Children with Special Health Care Needs', + 'Dual-Language Learners', + 'Infants and Toddlers (ages birth to 3)', + 'Parents/Families impacted by health disparities.', + 'Pregnant Women / Pregnant Persons', + 'Preschool Children (ages 3-5)', + ], + eventIntendedAudience: 'recipients', + 'National Center(s) Requested': 'PMFO', + 'Event Duration/# NC Days of Support': 'Series', + ...data, + }; +} + +export async function createTrainingReport(report) { + const { + collaboratorIds, + pocIds, + ownerId, + data, + } = report; + + let userCreator = await User.findByPk(ownerId); + if (!userCreator) { + userCreator = await createUser(); + } + + const userCollaborators = await Promise.all(collaboratorIds.map(async (id) => { + let user = await User.findByPk(id); + if (!user) { + user = await createUser(); + } + return user.id; + })); + + const userPocs = await Promise.all(pocIds.map(async (id) => { + let user = await User.findByPk(id); + if (!user) { + user = await createUser(); + } + return user.id; + })); + + return EventReportPilot.create({ + data: mockTrainingReportData(data || {}), + collaboratorIds: userCollaborators, + ownerId: userCreator.id, + regionId: userCreator.homeRegionId, + imported: {}, + pocIds: userPocs, + }); +} + +export function mockSessionData(data) { + return { + files: [ + { + id: 42698, + key: '709a0aec-b8a6-433f-8380-e2cdece1b492pdf', + url: { + url: faker.internet.url(), + error: null, + }, + status: 'QUEUEING_FAILED', + fileSize: 10306065, + createdAt: '2023-11-29T14:15:25.840Z', + updatedAt: '2023-11-29T14:15:26.276Z', + originalFileName: faker.system.fileName(), + }, + ], + status: TRAINING_REPORT_STATUSES.NOT_STARTED, + context: 'Participants will know what data to collect (CA, SA and other) and how to utilize that data for their baseline application.', + endDate: '10/23/2023', + eventId: '8030', + ownerId: null, + duration: 1, + regionId: 8, + eventName: faker.datatype.string(100), + objective: faker.datatype.string(100), + pageState: { + 1: 'Complete', + 2: 'Complete', + 3: 'Complete', + 4: 'Complete', + 5: 'In progress', + }, + startDate: '10/23/2023', + eventOwner: 305, + recipients: [ + { + label: faker.datatype.string(100), + value: faker.datatype.number(10000), + }, + { + label: faker.datatype.string(100), + value: faker.datatype.number(10000), + }, + { + label: faker.datatype.string(100), + value: faker.datatype.number(10000), + }, + ], + pocComplete: true, + sessionName: faker.datatype.string(100), + ttaProvided: faker.datatype.string(250), + participants: [ + 'CEO / CFO / Executive', + 'Coach', + 'Family Service Worker / Case Manager', + 'Fiscal Manager/Team', + 'Manager / Coordinator / Specialist', + 'Program Director (HS / EHS)', + 'Program Support / Administrative Assistant', + ], + pocCompleteId: '185', + deliveryMethod: 'virtual', + eventDisplayId: 'R08-TR-23-8030', + objectiveTopics: [ + 'Five-Year Grant', + 'Fiscal / Budget', + ], + pocCompleteDate: '2023-12-04', + objectiveTrainers: [ + 'PFMO', + ], + objectiveResources: [ + { + value: '', + }, + ], + recipientNextSteps: [ + { + note: 'Recipients will utilize sources of data from the self-assessment and the community assessment to inform the development of the baseline grant application.', + completeDate: '06/14/2024', + }, + ], + specialistNextSteps: [ + { + note: 'Specialists deployed to support recipients in developing a baseline grant will be available to answer questions related to using data from SA and CA. ', + completeDate: '02/09/2024', + }, + ], + numberOfParticipants: 33, + objectiveSupportType: 'Planning', + supportingAttachments: [], + 'pageVisited-supporting-attachments': 'true', + ...data, + }; +} + +export async function createSessionReport(report) { + const { eventId, data } = report; + + const event = await EventReportPilot.findOne({ + where: { id: eventId }, + attributes: ['id'], + }); + + return SessionReportPilot.create({ + data: mockSessionData(data || {}), + eventId: event?.id || await createTrainingReport({}).id, + }); +} diff --git a/src/widgets/helpers.js b/src/widgets/helpers.js index a695a52973..590f95bacb 100644 --- a/src/widgets/helpers.js +++ b/src/widgets/helpers.js @@ -2,9 +2,38 @@ import { Op } from 'sequelize'; import { REPORT_STATUSES } from '@ttahub/common'; import { ActivityReport, + Grant, + Recipient, sequelize, } from '../models'; +export async function getAllRecipientsFiltered(scopes) { + return Recipient.findAll({ + attributes: [ + [sequelize.fn('DISTINCT', sequelize.col('"Recipient"."id"')), 'id'], + ], + raw: true, + include: [ + { + attributes: ['regionId'], // This is required for scopes. + model: Grant, + as: 'grants', + required: true, + where: { + [Op.and]: [ + scopes.grant, + { endDate: { [Op.gt]: '2020-08-31' } }, + { deleted: { [Op.ne]: true } }, + { + [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], + }, + ], + }, + }, + ], + }); +} + export async function countOccurrences(scopes, column, possibilities) { const allOccurrences = await ActivityReport.findAll({ attributes: [ diff --git a/src/widgets/index.js b/src/widgets/index.js index 4e5a00df8e..015aee1e18 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -11,12 +11,14 @@ import goalStatusByGoalName from './goalStatusByGoalName'; import goalsByStatus from './regionalGoalDashboard/goalsByStatus'; import goalsPercentage from './regionalGoalDashboard/goalsPercentage'; import topicsByGoalStatus from './regionalGoalDashboard/topicsByGoalStatus'; +import trOverview from './trOverview'; /* All widgets need to be added to this object */ export default { overview, + trOverview, dashboardOverview, totalHrsAndRecipientGraph, reasonList, diff --git a/src/widgets/overview.js b/src/widgets/overview.js index a9f83ca812..78a310a715 100644 --- a/src/widgets/overview.js +++ b/src/widgets/overview.js @@ -8,34 +8,12 @@ import { Recipient, sequelize, } from '../models'; -import { formatNumber } from './helpers'; +import { formatNumber, getAllRecipientsFiltered } from './helpers'; export default async function overview(scopes) { // get all distinct recipient ids from recipients with the proper scopes applied - const allRecipientsFiltered = await Recipient.findAll({ - attributes: [ - [sequelize.fn('DISTINCT', sequelize.col('"Recipient"."id"')), 'id'], - ], - raw: true, - include: [ - { - attributes: ['regionId'], // This is required for scopes. - model: Grant, - as: 'grants', - required: true, - where: { - [Op.and]: [ - scopes.grant, - { endDate: { [Op.gt]: '2020-08-31' } }, - { deleted: { [Op.ne]: true } }, - { - [Op.or]: [{ inactivationDate: null }, { inactivationDate: { [Op.gt]: '2020-08-31' } }], - }, - ], - }, - }, - ], - }); + + const allRecipientsFiltered = await getAllRecipientsFiltered(scopes); // create a distinct array of recipient ids (we'll need this later, to filter the AR recipients) const totalRecipientIds = allRecipientsFiltered.map(({ id }) => id); diff --git a/src/widgets/trOverview.test.js b/src/widgets/trOverview.test.js new file mode 100644 index 0000000000..3c2a4f9e43 --- /dev/null +++ b/src/widgets/trOverview.test.js @@ -0,0 +1,259 @@ +import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; +import db, { + EventReportPilot, + SessionReportPilot, + Recipient, + Grant, + User, +} from '../models'; +import { + createUser, + createGrant, + createRecipient, + createSessionReport, + createTrainingReport, +} from '../testUtils'; +import trOverview from './trOverview'; + +// We need to mock this so that we don't try to send emails or otherwise engage the queue +jest.mock('bull'); + +describe('TR overview widget', () => { + let userCreator; + let userPoc; + let userCollaborator; + + let recipient1; + let recipient2; + let recipient3; + let recipient4; + let recipient5; + + let grant1; + let grant2; + let grant3; + let grant4; + let grant5; + + let trainingReport1; + let trainingReport2; + let trainingReport3; + + beforeAll(async () => { + // user/creator + userCreator = await createUser(); + // user/poc + userPoc = await createUser(); + // user/collaborator ID + userCollaborator = await createUser(); + + // recipient 1 + recipient1 = await createRecipient(); + // recipient 2 + recipient2 = await createRecipient(); + // recipient 3 + recipient3 = await createRecipient(); + // recipient 4 + recipient4 = await createRecipient(); + // recipient 5 (only on uncompleted report) + recipient5 = await createRecipient(); + + // grant 1 + grant1 = await createGrant({ recipientId: recipient1.id, regionId: userCreator.homeRegionId }); + // grant 2 + grant2 = await createGrant({ recipientId: recipient2.id, regionId: userCreator.homeRegionId }); + // grant 3 + grant3 = await createGrant({ recipientId: recipient3.id, regionId: userCreator.homeRegionId }); + // grant 4 + grant4 = await createGrant({ recipientId: recipient4.id, regionId: userCreator.homeRegionId }); + // grant 5 (only on uncompleted report) + grant5 = await createGrant({ recipientId: recipient5.id, regionId: userCreator.homeRegionId }); + + // training report 1 + trainingReport1 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }); + + // - session report 1 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + }, + }); + + // - session report 2 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + }, + }); + + // training report 2 + trainingReport2 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }); + + // - session report 3 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'hybrid', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 12, + numberOfParticipantsInPerson: 13, + numberOfParticipants: 0, + }, + }); + + // - session report 4 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant2.id }, { value: grant3.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + }, + }); + + // training report 3 (not completed) + trainingReport3 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }, { individualHooks: false }); + + // - session report 5 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + }, + }); + + // - session report 6 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + }, + }); + + // update TR 1 to complete + await trainingReport1.update({ + data: { + ...trainingReport1.data, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + + // update TR 2 to complete + await trainingReport2.update({ + data: { + ...trainingReport2.data, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + }); + + afterAll(async () => { + // delete session reports + await SessionReportPilot.destroy({ + where: { + eventId: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + // delete training reports + await EventReportPilot.destroy({ + where: { + id: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + await db.GrantNumberLink.destroy({ + where: { + grantId: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + force: true, + }); + + // delete grants + await Grant.destroy({ + where: { + id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + }); + + // delete recipients + await Recipient.destroy({ + where: { + id: [recipient1.id, recipient2.id, recipient3.id, recipient4.id, recipient5.id], + }, + }); + + // delete users + await User.destroy({ + where: { + id: [userCreator.id, userPoc.id, userCollaborator.id], + }, + }); + + await db.sequelize.close(); + }); + + it('filters and calculates training report', async () => { + // Confine this to the grants and reports that we created + const scopes = { + grant: [ + { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, + ], + trainingReport: [ + { id: [trainingReport1.id, trainingReport2.id, trainingReport3.id] }, + ], + }; + + // run our function + const data = await trOverview(scopes); + + // validate result + expect(data).toEqual({ + numGrants: '3', + numParticipants: '100', + numRecipients: '3', + numReports: '2', + recipientPercentage: '60.00%', + sumDuration: '4', + totalRecipients: '5', + }); + }); +}); diff --git a/src/widgets/trOverview.ts b/src/widgets/trOverview.ts new file mode 100644 index 0000000000..3a7488aeb5 --- /dev/null +++ b/src/widgets/trOverview.ts @@ -0,0 +1,180 @@ +import { Op, WhereOptions } from 'sequelize'; +import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; +import db from '../models'; +import { + formatNumber, + getAllRecipientsFiltered, +} from './helpers'; + +const { + EventReportPilot: TrainingReport, + SessionReportPilot: SessionReport, + Recipient, + Grant, +} = db; + +/** + * interface for scopes + */ + +interface IScopes { + grant: WhereOptions[], + trainingReport: WhereOptions[], +} + +/** + * Interface for the data returned by the Training Report findAll + * we use to calculate the data for the TR Overview widget + */ +interface ITrainingReportForOverview { + data: { + status: string, + } + sessionReports: { + data: { + status: string, + deliveryMethod: string, + duration: number, + recipients: { + value: number, + }[], + numberOfParticipantsVirtually: number, + numberOfParticipantsInPerson: number, + numberOfParticipants: number, + } + }[] +} + +/** + * Interface for the accumulator object used as an interim + * data structure to calculate the data for the TR Overview widget + */ +interface IReportData { + numReports: string; + totalRecipients: number; + grantIds: number[]; + sumDuration: number; + numParticipants: number; +} + +/** + * Interface for the data returned by the TR Overview widget + */ +interface IWidgetData { + numReports: string; + numGrants: string; + numRecipients: string; + totalRecipients: string; + sumDuration: string; + numParticipants: string; + recipientPercentage: string; +} + +/** + * Function to calculate the data for the TR Overview widget + * @param scopes + * @returns IWidgetData + */ +export default async function trOverview( + scopes: IScopes = { grant: [], trainingReport: [] }, +): Promise { + // get all recipients, matching how they are filtered in the AR overview + const allRecipientsFiltered = await getAllRecipientsFiltered(scopes); + + // Get all completed training reports and their session reports + const reports = await TrainingReport.findAll({ + attributes: ['data', 'id'], + where: { + [Op.and]: [ + { + 'data.status': TRAINING_REPORT_STATUSES.COMPLETE, + }, + ...scopes.trainingReport, + ], + }, + include: { + model: SessionReport, + as: 'sessionReports', + attributes: ['data', 'eventId'], + required: true, + }, + }) as ITrainingReportForOverview[]; + + const data = reports.reduce((acc: IReportData, report) => { + const { sessionReports } = report; + + let sessionGrants = []; + let sessionDuration = 0; + let sessionParticipants = 0; + + sessionReports.forEach((sessionReport) => { + const { data: sessionData } = sessionReport; + const { + deliveryMethod, + duration, + recipients, + numberOfParticipantsVirtually, + numberOfParticipantsInPerson, + numberOfParticipants, + } = sessionData; + + sessionDuration += duration; + sessionGrants = sessionGrants.concat(recipients.map((r: { value: number }) => r.value)); + + if (deliveryMethod === 'hybrid') { + sessionParticipants += numberOfParticipantsInPerson + numberOfParticipantsVirtually; + } else { + sessionParticipants += numberOfParticipants; + } + }); + + return { + ...acc, + grantIds: acc.grantIds.concat(sessionGrants), + sumDuration: acc.sumDuration + sessionDuration, + numParticipants: acc.numParticipants + sessionParticipants, + }; + }, { + numReports: formatNumber(reports.length), // number of completed TRs + totalRecipients: allRecipientsFiltered.length, // total number of recipients + grantIds: [], // number of unique grants served + sumDuration: 0, // total hours of TTA + numParticipants: 0, // total number of participants + } as IReportData); + + const uniqueGrants = new Set(data.grantIds); + + const recipientsOnTrs = await Recipient.findAll({ + attribute: ['id'], + include: [{ + attributes: ['id', 'recipientId'], + model: Grant, + as: 'grants', + where: { + id: { + [Op.in]: Array.from(uniqueGrants), + }, + }, + required: true, + }], + }); + + const numRecipients = recipientsOnTrs.length; + const rawPercentage = (numRecipients / data.totalRecipients) * 100; + const recipientPercentage = `${formatNumber(rawPercentage, 2)}%`; + + return { + numReports: data.numReports, + totalRecipients: allRecipientsFiltered.length.toString(), + // "X% [number of recipients with TR] Recipients of [total active recipients]" for complete TRs, + recipientPercentage, + // Add widget for "X of of unique grants on completed TRs + numGrants: formatNumber(uniqueGrants.size), + // total recipients + numRecipients: formatNumber(numRecipients), + // Add widget for number of hours of TTA on completed TRs + sumDuration: formatNumber(data.sumDuration), + // Add widget for number of participants on completed TRs + numParticipants: formatNumber(data.numParticipants), + }; +}