diff --git a/shared/locales/en/website-responses.json b/shared/locales/en/website-responses.json new file mode 100644 index 000000000..b066b3a6a --- /dev/null +++ b/shared/locales/en/website-responses.json @@ -0,0 +1,24 @@ +{ + "select-survey": "Select survey", + "multiple-answers": "multiple answers", + "responses-since": "Survey responses since {{sinceDate}}", + "title": "Survey Responses", + "answers": "answers", + "data-points": "data points", + "onboarding": { + "title": "Onboarding Survey", + "description": "Filled out once before recipient is joining the program" + }, + "checkin": { + "title": "Check-in Survey", + "description": "Filled out every 6 months while recipient is in the program" + }, + "offboarding": { + "title": "Offboarding Survey", + "description": "Filled out once when recipient is finishing the program" + }, + "offboarded-checkin": { + "title": "Follow-up Survey", + "description": "Filled out every 6 months after recipient left the program" + } +} diff --git a/shared/locales/en/website-survey.json b/shared/locales/en/website-survey.json index ecb26380b..76cd7434f 100644 --- a/shared/locales/en/website-survey.json +++ b/shared/locales/en/website-survey.json @@ -37,7 +37,9 @@ "questions": { "yesNoChoices": { "yes": "Yes", - "no": "No" + "no": "No", + "true": "Yes", + "false": "No" }, "livingLocationTitleV1": "Where do you live?", "livingLocationChoices": { diff --git a/shared/locales/it/website-survey.json b/shared/locales/it/website-survey.json index 2549d9d3a..842d547d8 100644 --- a/shared/locales/it/website-survey.json +++ b/shared/locales/it/website-survey.json @@ -29,7 +29,9 @@ "questions": { "yesNoChoices": { "yes": "Yes", - "no": "No" + "no": "No", + "true": "Yes", + "false": "No" }, "livingLocationTitleV1": "Where do you live?", "livingLocationChoices": { diff --git a/shared/locales/kri/website-survey.json b/shared/locales/kri/website-survey.json index 46ea5d5b0..6186af751 100644 --- a/shared/locales/kri/website-survey.json +++ b/shared/locales/kri/website-survey.json @@ -29,7 +29,9 @@ "questions": { "yesNoChoices": { "yes": "Yɛs", - "no": "Nɔ" + "no": "Nɔ", + "true": "Yɛs", + "false": "Nɔ" }, "livingLocationTitleV1": "Usay yu tap?", "livingLocationChoices": { diff --git a/shared/src/types/question.ts b/shared/src/types/question.ts new file mode 100644 index 000000000..ef493c0e7 --- /dev/null +++ b/shared/src/types/question.ts @@ -0,0 +1,271 @@ +export interface Question { + type: QuestionInputType; + name: string; + choices: any[]; + choicesTranslationKey?: string; + translationKey: string; + descriptionTranslationKey?: string; +} + +export enum QuestionInputType { + RADIO_GROUP = 'radiogroup', + COMMENT = 'comment', + CHECKBOX = 'checkbox', + RANKING = 'ranking', +} + +export const MARITAL_STATUS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'maritalStatusV1', + choices: ['married', 'widowed', 'divorced', 'separated', 'neverMarried'], + choicesTranslationKey: 'survey.questions.maritalStatusChoices', + translationKey: 'survey.questions.maritalStatusTitleV1', +}; + +export const LIVING_LOCATION: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'livingLocationV1', + choices: [ + 'westernAreaUrbanFreetown', + 'westernAreaRural', + 'easternProvince', + 'northernProvince', + 'southernProvince', + 'northWestProvince', + ], + translationKey: 'survey.questions.livingLocationTitleV1', + choicesTranslationKey: 'survey.questions.livingLocationChoices', +}; + +export const BOOLEAN_CHOICES = { choices: [true, false], choicesTranslationKey: 'survey.questions.yesNoChoices' }; + +export const HAS_DEPENDENTS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'hasDependentsV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.hasDependentsTitleV1', + descriptionTranslationKey: 'survey.questions.hasDependentsDescV1', +}; + +export const NOT_EMPLOYED: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'notEmployedV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.notEmployedTitleV1', +}; + +export const NUMBER_OF_DEPENDENTS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'nrDependentsV1', + choices: ['1-2', '3-4', '5-7', '8-10', '10-'], + translationKey: 'survey.questions.nrDependentsTitleV1', + choicesTranslationKey: 'survey.questions.nrDependentsChoices', +}; + +export const SCHOOL_ATTENDANCE: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'schoolAttendanceV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.attendingSchoolV1', +}; + +export const EMPLOYMENT_STATUS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'employmentStatusV1', + choices: ['employed', 'selfEmployed', 'notEmployed', 'retired'], + translationKey: 'survey.questions.employmentStatusTitleV1', + choicesTranslationKey: 'survey.questions.employmentStatusChoices', +}; +export const DISABILITY: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'disabilityV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.disabilityTitleV1', +}; + +export const SKIPPING_MEALS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'skippingMealsV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.skippingMealsTitleV1', +}; + +export const SKIPPING_MEALS_LAST_WEEK: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'skippingMealsLastWeekV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.skippingMealsLastWeekTitleV1', +}; + +export const SKIPPING_MEALS_LAST_WEEK_3_MEALS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'skippingMealsLastWeek3MealsV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.skippingMealsLastWeek3MealsTitleV1', +}; + +export const UNEXPECTED_EXPENSES_COVERED: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'unexpectedExpensesCoveredV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.unexpectedExpensesCoveredTitleV1', +}; +export const SAVINGS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'savingsV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.savingsTitleV1', +}; + +export const DEBT_PERSONAL: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'debtPersonalV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.debtPersonalTitleV1', +}; + +export const DEBT_PERSONAL_REPAY: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'debtPersonalRepayV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.debtPersonalRepayTitleV1', +}; + +export const DEBT_HOUSEHOLD: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'debtHouseholdV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.debtHouseholdTitleV1', +}; +export const DEBT_HOUSEHOLD_WHO_REPAYS: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'debtHouseholdWhoRepaysV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.debtHouseholdWhoRepaysTitleV1', +}; +export const OTHER_SUPPORT: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'otherSupportV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.otherSupportTitleV1', +}; + +export const PLANNED_ACHIEVEMENT: Question = { + type: QuestionInputType.COMMENT, + name: 'plannedAchievementV1', + translationKey: 'survey.questions.plannedAchievementTitleV1', + choices: [], +}; + +export const SPENDING: Question = { + type: QuestionInputType.CHECKBOX, + name: 'spendingV1', + choices: ['education', 'food', 'housing', 'healthCare', 'mobility', 'saving', 'investment'], + translationKey: 'survey.questions.spendingTitleV1', + choicesTranslationKey: 'survey.questions.spendingChoices', +}; + +export const PLANNED_ACHIEVEMENT_REMAINING: Question = { + type: QuestionInputType.COMMENT, + name: 'plannedAchievementRemainingV1', + translationKey: 'survey.questions.plannedAchievementRemainingTitleV1', + choices: [], +}; + +export const IMPACT_FINANCIAL_INDEPENDENCE: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'impactFinancialIndependenceV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.financialIndependenceTitleV1', +}; + +export const IMPACT_LIFE_GENERAL: Question = { + type: QuestionInputType.COMMENT, + name: 'impactLifeGeneralV1', + translationKey: 'survey.questions.impactLifeGeneralTitleV1', + choices: [], +}; + +export const ACHIEVEMENTS_ACHIEVED: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'achievementsAchievedV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.achievementsAchievedTitleV1', +}; + +export const ACHIEVEMENTS_NOT_ACHIEVED: Question = { + type: QuestionInputType.COMMENT, + name: 'achievementsNotAchievedCommentV1', + choices: [], + translationKey: 'survey.questions.achievementsNotAchievedCommentTitleV1', +}; + +export const HAPPIER: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'happierV1', + ...BOOLEAN_CHOICES, + translationKey: 'survey.questions.happierTitleV1', +}; + +export const HAPPIER_COMMENT: Question = { + type: QuestionInputType.COMMENT, + name: 'happierCommentV1', + translationKey: 'survey.questions.happierCommentTitleV1', + choices: [], +}; +export const NOT_HAPPIER_COMMENT: Question = { + type: QuestionInputType.COMMENT, + translationKey: 'survey.questions.notHappierCommentTitleV1', + name: 'notHappierCommentV1', + choices: [], +}; + +export const LONG_ENOUGH: Question = { + type: QuestionInputType.RADIO_GROUP, + name: 'longEnough', + translationKey: 'survey.questions.longEnoughTitleV1', + ...BOOLEAN_CHOICES, +}; + +export const RANKING: Question = { + type: QuestionInputType.RANKING, + name: 'spendingRankedV1', + choices: [], + translationKey: 'survey.questions.spendingRankingTitleV1', + descriptionTranslationKey: 'survey.questions.spendingRankingDescV1', +}; + +const ALL_QUESTIONS = [ + MARITAL_STATUS, + LIVING_LOCATION, + HAS_DEPENDENTS, + NUMBER_OF_DEPENDENTS, + SCHOOL_ATTENDANCE, + EMPLOYMENT_STATUS, + DISABILITY, + SKIPPING_MEALS, + SKIPPING_MEALS_LAST_WEEK, + SKIPPING_MEALS_LAST_WEEK_3_MEALS, + UNEXPECTED_EXPENSES_COVERED, + SAVINGS, + DEBT_PERSONAL, + DEBT_PERSONAL_REPAY, + DEBT_HOUSEHOLD, + DEBT_HOUSEHOLD_WHO_REPAYS, + OTHER_SUPPORT, + PLANNED_ACHIEVEMENT, + SPENDING, + PLANNED_ACHIEVEMENT_REMAINING, + IMPACT_FINANCIAL_INDEPENDENCE, + IMPACT_LIFE_GENERAL, + ACHIEVEMENTS_ACHIEVED, + ACHIEVEMENTS_NOT_ACHIEVED, + HAPPIER, + HAPPIER_COMMENT, + NOT_HAPPIER_COMMENT, + LONG_ENOUGH, + RANKING, + NOT_EMPLOYED, +]; + +export const QUESTIONS_DICTIONARY: Map = new Map(ALL_QUESTIONS.map((obj) => [obj.name, obj])); diff --git a/shared/src/utils/stats/SurveyStatsCalculator.test.ts b/shared/src/utils/stats/SurveyStatsCalculator.test.ts new file mode 100644 index 000000000..7bd039531 --- /dev/null +++ b/shared/src/utils/stats/SurveyStatsCalculator.test.ts @@ -0,0 +1,239 @@ +import { beforeAll, expect, test } from '@jest/globals'; +import functions from 'firebase-functions-test'; +import { getOrInitializeFirebaseAdmin } from '../../firebase/admin/app'; +import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin'; +import { toFirebaseAdminTimestamp } from '../../firebase/admin/utils'; +import { EMPLOYMENT_STATUS, SPENDING, UNEXPECTED_EXPENSES_COVERED } from '../../types/question'; +import { RECIPIENT_FIRESTORE_PATH, RecipientMainLanguage } from '../../types/recipient'; +import { SURVEY_FIRETORE_PATH, SurveyQuestionnaire, SurveyStatus } from '../../types/survey'; +import { SurveyStatsCalculator } from './SurveyStatsCalculator'; + +const projectId = 'survey-stats-calculator-test'; +const testEnv = functions({ projectId }); +const firestoreAdmin = new FirestoreAdmin(getOrInitializeFirebaseAdmin({ projectId })); +let calculator: SurveyStatsCalculator; + +beforeAll(async () => { + await testEnv.firestore.clearFirestoreData({ projectId }); + await insertTestData(); + calculator = await SurveyStatsCalculator.build(firestoreAdmin); +}); +const OLDEST_DATE = new Date('2022-01-01'); + +test('building SurveyStatsCalculator', async () => { + expect(calculator.data).toBeDefined(); + expect(calculator.data.length).toBeGreaterThan(0); + expect(calculator.oldestDate.getFullYear()).toEqual(OLDEST_DATE.getFullYear()); + expect(calculator.oldestDate.getMonth()).toEqual(OLDEST_DATE.getMonth()); + expect(calculator.oldestDate.getDay()).toEqual(OLDEST_DATE.getDay()); +}); + +test('calculate overall survey stats', async () => { + expect(calculator.data).toContainEqual( + expect.objectContaining({ + type: SurveyQuestionnaire.Checkin, + total: 3, + }), + ); + expect(calculator.data).toContainEqual( + expect.objectContaining({ + type: SurveyQuestionnaire.Onboarding, + total: 1, + }), + ); + expect(calculator.data).toContainEqual( + expect.objectContaining({ + type: SurveyQuestionnaire.OffboardedCheckin, + total: 3, + }), + ); + expect(calculator.data).toContainEqual( + expect.objectContaining({ + type: SurveyQuestionnaire.Offboarding, + total: 1, + }), + ); +}); + +test('calculate aggregated survey responses by question type', async () => { + const aggregatedData = calculator.aggregatedData; + expect(aggregatedData).toBeDefined(); + + const checkinData = aggregatedData[SurveyQuestionnaire.Checkin]; + const offboardedCheckin = aggregatedData[SurveyQuestionnaire.OffboardedCheckin]; + + // RADIO_GROUP text + expect(checkinData[EMPLOYMENT_STATUS.name].answers['selfEmployed']).toBe(2); + expect(checkinData[EMPLOYMENT_STATUS.name].answers['employed']).toBe(1); + + // RADIO_GROUP boolean + expect(offboardedCheckin[UNEXPECTED_EXPENSES_COVERED.name].answers['true']).toBe(2); + expect(offboardedCheckin[UNEXPECTED_EXPENSES_COVERED.name].answers['false']).toBe(1); + + // CHECKBOX type + expect(checkinData[SPENDING.name].answers['food']).toBe(2); + expect(checkinData[SPENDING.name].answers['housing']).toBe(2); +}); + +test('handle edge cases with empty data', async () => { + await testEnv.firestore.clearFirestoreData({ projectId }); + const emptyCalculator = await SurveyStatsCalculator.build(firestoreAdmin); + expect(emptyCalculator.data).toEqual([]); + expect(emptyCalculator.aggregatedData).toEqual({ + [SurveyQuestionnaire.Checkin]: {}, + [SurveyQuestionnaire.Onboarding]: {}, + [SurveyQuestionnaire.OffboardedCheckin]: {}, + [SurveyQuestionnaire.Offboarding]: {}, + }); +}); + +const surveyRecords = [ + [ + { + questionnaire: SurveyQuestionnaire.Checkin, + recipient_name: 'John Doe', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + employmentStatusV1: 'selfEmployed', // RADIO_GROUP + spendingV1: ['housing'], // CHECKBOX + plannedAchievementRemainingV1: 'Save for a car', // COMMENT + }, + access_email: 'john.doe@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.Checkin, + recipient_name: 'Jane Smith', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + employmentStatusV1: 'employed', // RADIO_GROUP + spendingV1: ['food'], // CHECKBOX + plannedAchievementRemainingV1: 'Buy a house', // COMMENT + }, + access_email: 'jane.smith@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.Checkin, + recipient_name: 'Alice Brown', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(OLDEST_DATE), + status: SurveyStatus.Completed, + data: { + employmentStatusV1: 'selfEmployed', // RADIO_GROUP + spendingV1: ['housing', 'food'], // CHECKBOX (multiple selections) + }, + access_email: 'alice.brown@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + ], + + [ + { + questionnaire: SurveyQuestionnaire.Onboarding, + recipient_name: 'Alice Brown', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + livingLocationV1: 'easternProvince', // RADIO_GROUP + plannedAchievementV1: 'Save for a car', // COMMENT + unexpectedExpensesCoveredV1: false, + }, + access_email: 'alice.brown@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.OffboardedCheckin, + recipient_name: 'Bob Green', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + unexpectedExpensesCoveredV1: false, // BOOLEAN + }, + access_email: 'bob.green@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.OffboardedCheckin, + recipient_name: 'Bob Green', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + unexpectedExpensesCoveredV1: true, // BOOLEAN + }, + access_email: 'bob.green@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.OffboardedCheckin, + recipient_name: 'Bob Green', + language: RecipientMainLanguage.English, + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + maritalStatusV1: 'married', // RADIO_GROUP + unexpectedExpensesCoveredV1: true, // BOOLEAN + }, + access_email: 'bob.green@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + { + questionnaire: SurveyQuestionnaire.Offboarding, + recipient_name: 'Bob Green', + language: 'en', + due_date_at: toFirebaseAdminTimestamp(new Date()), + sent_at: toFirebaseAdminTimestamp(new Date()), + completed_at: toFirebaseAdminTimestamp(new Date()), + status: SurveyStatus.Completed, + data: { + maritalStatusV1: 'married', // RADIO_GROUP + unexpectedExpensesCoveredV1: true, // BOOLEAN + }, + access_email: 'bob.green@example.com', + access_pw: 'password123', + access_token: 'token123', + }, + ], +]; +const insertTestData = async () => { + for (let surveyData of surveyRecords) { + for (let is_test of [false, true]) { + const recipientRef = await firestoreAdmin.collection(RECIPIENT_FIRESTORE_PATH).add({ test_recipient: is_test }); + await Promise.all( + surveyData.map((survey) => + firestoreAdmin + .collection(`${RECIPIENT_FIRESTORE_PATH}/${recipientRef.id}/${SURVEY_FIRETORE_PATH}`) + .add(survey), + ), + ); + } + } +}; diff --git a/shared/src/utils/stats/SurveyStatsCalculator.ts b/shared/src/utils/stats/SurveyStatsCalculator.ts new file mode 100644 index 000000000..aa03938e0 --- /dev/null +++ b/shared/src/utils/stats/SurveyStatsCalculator.ts @@ -0,0 +1,133 @@ +import { FirestoreAdmin } from '../../firebase/admin/FirestoreAdmin'; +import { Question, QuestionInputType, QUESTIONS_DICTIONARY } from '../../types/question'; +import { Recipient, RECIPIENT_FIRESTORE_PATH } from '../../types/recipient'; +import { Survey, SURVEY_FIRETORE_PATH, SurveyQuestionnaire, SurveyStatus } from '../../types/survey'; + +export interface SurveyStats { + total: number; + type: SurveyQuestionnaire; +} + +export interface SurveyAnswersByType { + answers: { [type: string]: number }; + total: number; + question: Question; +} + +type AggregatedSurveyData = { [key in SurveyQuestionnaire]: { [questionKey: string]: SurveyAnswersByType } }; + +const SUPPORTED_SURVEY_QUESTION_TYPES = [QuestionInputType.RADIO_GROUP, QuestionInputType.CHECKBOX]; + +export class SurveyStatsCalculator { + private readonly _data: SurveyStats[]; + private readonly _aggregatedData: AggregatedSurveyData; + private readonly _oldestDate: Date; + + private constructor(data: SurveyStats[], aggregatedData: AggregatedSurveyData, oldestDate: Date) { + this._data = data; + this._aggregatedData = aggregatedData; + this._oldestDate = oldestDate; + } + + get oldestDate(): Date { + return this._oldestDate; + } + + get data(): SurveyStats[] { + return this._data; + } + + get aggregatedData(): AggregatedSurveyData { + return this._aggregatedData; + } + + /** + * Builds a new instance of SurveyStatsCalculator + * @param firestoreAdmin Firestore admin instance for accessing the database + */ + static async build(firestoreAdmin: FirestoreAdmin): Promise { + const recipients = await firestoreAdmin.collection(RECIPIENT_FIRESTORE_PATH).get(); + const surveysData = await this.fetchAndProcessSurveys(firestoreAdmin, recipients); + const { typeCounts, aggregatedData, oldestDate } = this.aggregateSurveyData(surveysData); + const data = Object.entries(typeCounts).map(([type, total]) => ({ type, total }) as SurveyStats); + return new SurveyStatsCalculator(data, aggregatedData, oldestDate); + } + + private static async fetchAndProcessSurveys( + firestoreAdmin: FirestoreAdmin, + recipients: FirebaseFirestore.QuerySnapshot, + ): Promise { + const surveySnapshots = await Promise.all( + recipients.docs + .filter((recipient) => !recipient.get('test_recipient')) + .map((recipient) => + firestoreAdmin + .collection(`${RECIPIENT_FIRESTORE_PATH}/${recipient.id}/${SURVEY_FIRETORE_PATH}`) + .get(), + ), + ); + return surveySnapshots.flatMap((snapshot) => snapshot.docs.map((doc) => doc.data())); + } + + private static aggregateSurveyData(surveysData: Survey[]): { + typeCounts: { [type: string]: number }; + aggregatedData: AggregatedSurveyData; + oldestDate: Date; + } { + const aggregatedSurveyData: AggregatedSurveyData = { + [SurveyQuestionnaire.Checkin]: {}, + [SurveyQuestionnaire.Onboarding]: {}, + [SurveyQuestionnaire.OffboardedCheckin]: {}, + [SurveyQuestionnaire.Offboarding]: {}, + }; + const typeCounts: { [type: string]: number } = {}; + let oldestDate = new Date(); + + surveysData.forEach((survey) => { + if (this.isCompletedSurvey(survey)) { + oldestDate = + survey.completed_at?.toDate() && survey.completed_at?.toDate() < oldestDate + ? survey.completed_at.toDate() + : oldestDate; + const questionnaire = survey.questionnaire!; + typeCounts[questionnaire] = (typeCounts[questionnaire] || 0) + 1; + Object.entries(survey.data!).forEach(([questionKey, response]) => { + this.processSurveyResponse(aggregatedSurveyData, questionnaire, questionKey, response); + }); + } + }); + + return { typeCounts, aggregatedData: aggregatedSurveyData, oldestDate }; + } + + private static isCompletedSurvey(survey: Survey): boolean { + return !!(survey.data && survey.questionnaire && survey.status === SurveyStatus.Completed); + } + + private static processSurveyResponse( + aggregatedData: AggregatedSurveyData, + questionnaire: SurveyQuestionnaire, + questionKey: string, + response: any, + ): void { + const question = QUESTIONS_DICTIONARY.get(questionKey); + if (!question || !SUPPORTED_SURVEY_QUESTION_TYPES.includes(question.type)) return; + + aggregatedData[questionnaire][questionKey] = aggregatedData[questionnaire][questionKey] || { + answers: {}, + total: 0, + question, + }; + const questionData = aggregatedData[questionnaire][questionKey]; + + if (question.type === QuestionInputType.CHECKBOX) { + (response as string[]).forEach((value) => { + questionData.answers[value] = (questionData.answers[value] || 0) + 1; + }); + } else if (question.type === QuestionInputType.RADIO_GROUP) { + const responseValue = response as string; + questionData.answers[responseValue] = (questionData.answers[responseValue] || 0) + 1; + } + questionData.total++; + } +} diff --git a/ui/src/components/badge.tsx b/ui/src/components/badge.tsx index c6aa2fdea..6e00e44b1 100644 --- a/ui/src/components/badge.tsx +++ b/ui/src/components/badge.tsx @@ -12,6 +12,7 @@ const badgeVariants = cva( secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary-muted', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive-muted', muted: 'border-muted-foreground text-muted-foreground hover:bg-muted-foreground hover:text-muted', + accent: 'border-transparent bg-accent text-accent-foreground hover:bg-muted-foreground hover:text-muted', outline: 'text-primary border-primary hover:bg-primary-muted hover:text-primary-foreground hover:border-primary-muted', }, diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component.tsx b/website/src/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component.tsx new file mode 100644 index 000000000..778da4ae6 --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component.tsx @@ -0,0 +1,61 @@ +'use client'; +import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts'; + +export interface ChartData { + name: string; + value: number; +} + +function BarchartSurveyResponseComponent({ data }: { data: ChartData[] }) { + const barHeight = 30; + const chartHeight = data.length * barHeight + 40; + + let fillColor = 'hsl(var(--foreground))'; + + const customLabel = (props: any) => { + const { x, y, width, value } = props; + return ( + + {value} + {value} + + ); + }; + + return ( + + + + + + + `${value}%`} + minTickGap={1} + /> + + + ); +} + +export default BarchartSurveyResponseComponent; diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx b/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx new file mode 100644 index 000000000..297ebd21c --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx @@ -0,0 +1,117 @@ +import { DefaultPageProps } from '@/app/[lang]/[region]'; +import BarchartSurveyResponseComponent, { + ChartData, +} from '@/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component'; +import { firestoreAdmin } from '@/firebase-admin'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'; +import { QuestionInputType } from '@socialincome/shared/src/types/question'; +import { SurveyQuestionnaire } from '@socialincome/shared/src/types/survey'; +import { Translator } from '@socialincome/shared/src/utils/i18n'; +import { SurveyAnswersByType, SurveyStatsCalculator } from '@socialincome/shared/src/utils/stats/SurveyStatsCalculator'; +import { + Badge, + BaseContainer, + Card, + CardContent, + CardHeader, + CardTitle, + Separator, + Typography, +} from '@socialincome/ui'; +import { Fragment } from 'react'; + +export const revalidate = 3600; // update once an hour + +export default async function Page({ params: { lang } }: DefaultPageProps) { + const { aggregatedData: data, data: temp, oldestDate } = await SurveyStatsCalculator.build(firestoreAdmin); + + const allSurveyData = Object.values(SurveyQuestionnaire) + .map((it) => temp.find((survey) => survey.type == it)) + .filter((it) => !!it); + + const translator = await Translator.getInstance({ + language: lang, + namespaces: ['website-responses', 'website-survey'], + }); + + function convertToBarchartData(surveyAnswersByType: SurveyAnswersByType): ChartData[] { + return Object.values(surveyAnswersByType.question.choices) + .map((key) => ({ + name: translator.t(surveyAnswersByType.question.choicesTranslationKey! + '.' + key), + value: Math.round(((surveyAnswersByType.answers[key] || 0) / surveyAnswersByType.total) * 100), + })) + .sort((a, b) => b.value - a.value); + } + + return ( + + + {translator.t('title')} + + + + {translator.t('select-survey')} + + + + {allSurveyData.map( + (surveyData) => + surveyData && ( + + + + {translator.t(`${surveyData.type}.title`)} + + + {translator.t(`${surveyData.type}.description`)} + + {surveyData.total} {translator.t('data-points')} + + + + + ), + )} + + + {translator.t('responses-since', { + context: { sinceDate: oldestDate.toLocaleDateString() }, + })} + + + {Object.values(SurveyQuestionnaire).map((selectedSurvey) => ( + +
+ {Object.keys(data[selectedSurvey] || []).map((questionKey) => ( + + +
+ + {translator.t(data[selectedSurvey][questionKey].question.translationKey)} + {data[selectedSurvey][questionKey].question.type == QuestionInputType.CHECKBOX && ( + ({translator.t('multiple-answers')}) + )} + + + + {data[selectedSurvey][questionKey].total} {translator.t('answers')} + + +
+
+ {data[selectedSurvey][questionKey].answers && ( + + )} +
+
+ ))} +
+
+ ))} +
+ +
+ ); +} diff --git a/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questionnaires.ts b/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questionnaires.ts index 3b58aa6eb..81175b674 100644 --- a/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questionnaires.ts +++ b/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questionnaires.ts @@ -44,10 +44,10 @@ export const onboardingQuestionnaire = (t: TranslateFunction, name: string) => [ livingLocationPage(t), maritalStatusPage(t), dependentsPage(t), - schoolAttendancePage, + schoolAttendancePage(t), employmentStatusPage(t), disabilityPage(t), - skippingMealsPage, + skippingMealsPage(t), unexpectedExpensesCoveredPage(t), savingsPage(t), debtPersonalPage(t), @@ -62,10 +62,10 @@ export const checkinQuestionnaire = (t: TranslateFunction, name: string) => [ livingLocationPage(t), maritalStatusPage(t), dependentsPage(t), - schoolAttendancePage, + schoolAttendancePage(t), employmentStatusPage(t), disabilityPage(t), - skippingMealsPage, + skippingMealsPage(t), unexpectedExpensesCoveredPage(t), savingsPage(t), debtPersonalPage(t), @@ -83,10 +83,10 @@ export const offboardingQuestionnaire = (t: TranslateFunction, name: string) => livingLocationPage(t), maritalStatusPage(t), dependentsPage(t), - schoolAttendancePage, + schoolAttendancePage(t), employmentStatusPage(t), disabilityPage(t), - skippingMealsPage, + skippingMealsPage(t), unexpectedExpensesCoveredPage(t), savingsPage(t), ]; @@ -98,10 +98,10 @@ export const offboardingCheckinQuestionnaire = (t: TranslateFunction, name: stri livingLocationPage(t), maritalStatusPage(t), dependentsPage(t), - schoolAttendancePage, + schoolAttendancePage(t), employmentStatusPage(t), disabilityPage(t), - skippingMealsPage, + skippingMealsPage(t), unexpectedExpensesCoveredPage(t), savingsPage(t), ]; diff --git a/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questions.ts b/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questions.ts index 4b9a15766..759718b6c 100644 --- a/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questions.ts +++ b/website/src/app/[lang]/[region]/survey/[recipient]/[survey]/questions.ts @@ -1,4 +1,37 @@ // Generic set of question pages and choices +import { + ACHIEVEMENTS_ACHIEVED, + ACHIEVEMENTS_NOT_ACHIEVED, + DEBT_HOUSEHOLD, + DEBT_HOUSEHOLD_WHO_REPAYS, + DEBT_PERSONAL, + DEBT_PERSONAL_REPAY, + DISABILITY, + EMPLOYMENT_STATUS, + HAPPIER, + HAPPIER_COMMENT, + HAS_DEPENDENTS, + IMPACT_FINANCIAL_INDEPENDENCE, + IMPACT_LIFE_GENERAL, + LIVING_LOCATION, + LONG_ENOUGH, + MARITAL_STATUS, + NOT_EMPLOYED, + NOT_HAPPIER_COMMENT, + NUMBER_OF_DEPENDENTS, + OTHER_SUPPORT, + PLANNED_ACHIEVEMENT, + PLANNED_ACHIEVEMENT_REMAINING, + Question, + RANKING, + SAVINGS, + SCHOOL_ATTENDANCE, + SKIPPING_MEALS, + SKIPPING_MEALS_LAST_WEEK, + SKIPPING_MEALS_LAST_WEEK_3_MEALS, + SPENDING, + UNEXPECTED_EXPENSES_COVERED, +} from '@socialincome/shared/src/types/question'; import { TranslateFunction } from '@socialincome/shared/src/utils/i18n'; // Final question pages @@ -24,15 +57,25 @@ export const welcomePage = (t: TranslateFunction, name: string) => { // Questions for onboarding survey (reused in other surveys) +function getSimpleMapping(question: Question, t: TranslateFunction): Object { + return { + type: question.type, + name: question.name, + title: t(question.translationKey), + description: question.descriptionTranslationKey && t(question.descriptionTranslationKey), + choices: + question.choices && question.choices.length + ? translateChoices(t, question.choices, question.choicesTranslationKey!) + : undefined, + }; +} + export const livingLocationPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'livingLocationV1', + ...getSimpleMapping(LIVING_LOCATION, t), isRequired: true, - title: t('survey.questions.livingLocationTitleV1'), - choices: livingLocationChoices(t), }, ], }; @@ -42,11 +85,8 @@ export const maritalStatusPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'maritalStatusV1', + ...getSimpleMapping(MARITAL_STATUS, t), isRequired: true, - title: t('survey.questions.maritalStatusTitleV1'), - choices: maritalStatusChoices(t), }, ], }; @@ -56,20 +96,13 @@ export const dependentsPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'hasDependentsV1', + ...getSimpleMapping(HAS_DEPENDENTS, t), isRequired: true, - title: t('survey.questions.hasDependentsTitleV1'), - description: t('survey.questions.hasDependentsDescV1'), - choices: yesNoChoices(t), }, { - type: 'radiogroup', - name: 'nrDependentsV1', + ...getSimpleMapping(NUMBER_OF_DEPENDENTS, t), isRequired: true, - title: t('survey.questions.nrDependentsTitleV1'), visibleIf: '{hasDependentsV1}=true', - choices: nrDependentsChoices(t), }, ], }; @@ -79,11 +112,8 @@ export const schoolAttendancePage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'schoolAttendanceV1', + ...getSimpleMapping(SCHOOL_ATTENDANCE, t), isRequired: true, - title: t('survey.questions.attendingSchoolV1'), - choices: yesNoChoices(t), }, ], }; @@ -93,19 +123,13 @@ export const employmentStatusPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'employmentStatusV1', + ...getSimpleMapping(EMPLOYMENT_STATUS, t), isRequired: true, - title: t('survey.questions.employmentStatusTitleV1'), - choices: employmentStatusChoices(t), }, { - type: 'radiogroup', - name: 'notEmployedV1', + ...getSimpleMapping(NOT_EMPLOYED, t), visibleIf: '{employmentStatusV1}=notEmployed', - title: t('survey.questions.notEmployedTitleV1'), isRequired: true, - choices: yesNoChoices(t), }, ], }; @@ -115,11 +139,8 @@ export const disabilityPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'disabilityV1', + ...getSimpleMapping(DISABILITY, t), isRequired: true, - title: t('survey.questions.disabilityTitleV1'), - choices: yesNoChoices(t), }, ], }; @@ -129,27 +150,18 @@ export const skippingMealsPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'skippingMealsV1', + ...getSimpleMapping(SKIPPING_MEALS, t), isRequired: true, - title: t('survey.questions.skippingMealsTitleV1'), - choices: yesNoChoices(t), }, { - type: 'radiogroup', - name: 'skippingMealsLastWeekV1', + ...getSimpleMapping(SKIPPING_MEALS_LAST_WEEK, t), visibleIf: '{skippingMealsV1}=true', - title: t('survey.questions.skippingMealsLastWeekTitleV1'), isRequired: true, - choices: yesNoChoices(t), }, { - type: 'radiogroup', - name: 'skippingMealsLastWeek3MealsV1', + ...getSimpleMapping(SKIPPING_MEALS_LAST_WEEK_3_MEALS, t), visibleIf: '{skippingMealsLastWeekV1}=true', - title: t('survey.questions.skippingMealsLastWeek3MealsTitleV1'), isRequired: true, - choices: yesNoChoices(t), }, ], }; @@ -159,11 +171,8 @@ export const unexpectedExpensesCoveredPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'unexpectedExpensesCoveredV1', + ...getSimpleMapping(UNEXPECTED_EXPENSES_COVERED, t), isRequired: true, - title: t('survey.questions.unexpectedExpensesCoveredTitleV1'), - choices: yesNoChoices(t), }, ], }; @@ -173,11 +182,8 @@ export const savingsPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'savingsV1', + ...getSimpleMapping(SAVINGS, t), isRequired: true, - title: t('survey.questions.savingsTitleV1'), - choices: yesNoChoices(t), }, ], }; @@ -187,19 +193,13 @@ export const debtPersonalPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'debtPersonalV1', + ...getSimpleMapping(DEBT_PERSONAL, t), isRequired: true, - title: t('survey.questions.debtPersonalTitleV1'), - choices: yesNoChoices(t), }, { - type: 'radiogroup', - name: 'debtPersonalRepayV1', + ...getSimpleMapping(DEBT_PERSONAL_REPAY, t), visibleIf: '{debtPersonalV1}=true', - title: t('survey.questions.debtPersonalRepayTitleV1'), isRequired: true, - choices: yesNoChoices(t), }, ], }; @@ -209,19 +209,13 @@ export const debtHouseholdPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'debtHouseholdV1', + ...getSimpleMapping(DEBT_HOUSEHOLD, t), isRequired: true, - title: t('survey.questions.debtHouseholdTitleV1'), - choices: yesNoChoices(t), }, { - type: 'radiogroup', - name: 'debtHouseholdWhoRepaysV1', + ...getSimpleMapping(DEBT_HOUSEHOLD_WHO_REPAYS, t), visibleIf: '{debtHouseholdV1}=true', - title: t('survey.questions.debtHouseholdWhoRepaysTitleV1'), isRequired: true, - choices: yesNoChoices(t), }, ], }; @@ -231,11 +225,8 @@ export const otherSupportPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'otherSupportV1', + ...getSimpleMapping(OTHER_SUPPORT, t), isRequired: true, - title: t('survey.questions.otherSupportTitleV1'), - choices: yesNoChoices(t), }, ], }; @@ -245,10 +236,8 @@ export const plannedAchievementsPage = (t: TranslateFunction) => { return { elements: [ { - type: 'comment', - name: 'plannedAchievementV1', + ...getSimpleMapping(PLANNED_ACHIEVEMENT, t), isRequired: true, - title: t('survey.questions.plannedAchievementTitleV1'), }, ], }; @@ -260,17 +249,11 @@ export const spendingPage = (t: TranslateFunction) => { return { elements: [ { - type: 'checkbox', - name: 'spendingV1', + ...getSimpleMapping(SPENDING, t), isRequired: true, - title: t('survey.questions.spendingTitleV1'), - choices: spendingChoices(t), }, { - type: 'ranking', - name: 'spendingRankedV1', - title: t('survey.questions.spendingRankingTitleV1'), - description: t('survey.questions.spendingRankingDescV1'), + ...getSimpleMapping(RANKING, t), visibleIf: '{spendingV1.length} > 1', isRequired: true, choicesFromQuestion: 'spendingV1', @@ -284,10 +267,8 @@ export const plannedAchievementsRemainingPage = (t: TranslateFunction) => { return { elements: [ { - type: 'comment', - name: 'plannedAchievementRemainingV1', + ...getSimpleMapping(PLANNED_ACHIEVEMENT_REMAINING, t), isRequired: true, - title: t('survey.questions.plannedAchievementRemainingTitleV1'), }, ], }; @@ -299,11 +280,8 @@ export const impactFinancialPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'impactFinancialIndependenceV1', + ...getSimpleMapping(IMPACT_FINANCIAL_INDEPENDENCE, t), isRequired: true, - title: t('survey.questions.financialIndependenceTitleV1'), - choices: yesNoChoices(t), }, ], }; @@ -313,10 +291,8 @@ export const impactLifePage = (t: TranslateFunction) => { return { elements: [ { - type: 'comment', - name: 'impactLifeGeneralV1', + ...getSimpleMapping(IMPACT_LIFE_GENERAL, t), isRequired: true, - title: t('survey.questions.impactLifeGeneralTitleV1'), }, ], }; @@ -326,17 +302,13 @@ export const achievementsAchievedPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'achievementsAchievedV1', + ...getSimpleMapping(ACHIEVEMENTS_ACHIEVED, t), isRequired: true, - title: t('survey.questions.achievementsAchievedTitleV1'), - choices: yesNoChoices(t), }, { - type: 'comment', - name: 'achievementsNotAchievedCommentV1', + ...getSimpleMapping(ACHIEVEMENTS_NOT_ACHIEVED, t), + visibleIf: '{achievementsAchievedV1}=false', - title: t('survey.questions.achievementsNotAchievedCommentTitleV1'), isRequired: true, }, ], @@ -347,24 +319,17 @@ export const happierPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'happierV1', + ...getSimpleMapping(HAPPIER, t), isRequired: true, - title: t('survey.questions.happierTitleV1'), - choices: yesNoChoices(t), }, { - type: 'comment', - name: 'happierCommentV1', + ...getSimpleMapping(HAPPIER_COMMENT, t), visibleIf: '{happier}=true', - title: t('survey.questions.happierCommentTitleV1'), isRequired: true, }, { - type: 'comment', - name: 'notHappierCommentV1', + ...getSimpleMapping(NOT_HAPPIER_COMMENT, t), visibleIf: '{happierCommentV1}=false', - title: t('survey.questions.notHappierCommentTitleV1'), isRequired: true, }, ], @@ -375,73 +340,17 @@ export const longEnoughPage = (t: TranslateFunction) => { return { elements: [ { - type: 'radiogroup', - name: 'longEnough', + ...getSimpleMapping(LONG_ENOUGH, t), isRequired: true, - title: t('survey.questions.longEnoughTitleV1'), - choices: yesNoChoices(t), }, ], }; }; -// Additional questions for check-in survey for former recipients - -// Reusable choices - -export const yesNoChoices = (t: TranslateFunction) => - [ - [true, 'yes'], - [false, 'no'], - ].map(([value, translationKey]) => { - return { - value: value, - text: t('survey.questions.yesNoChoices.' + translationKey), - }; - }); - -export const maritalStatusChoices = (t: TranslateFunction) => - ['married', 'widowed', 'divorced', 'separated', 'neverMarried'].map((key) => { - return { - value: key, - text: t('survey.questions.maritalStatusChoices.' + key), - }; - }); - -export const nrDependentsChoices = (t: TranslateFunction) => - ['1-2', '3-4', '5-7', '8-10', '10-'].map((key) => { - return { - value: key, - text: t('survey.questions.nrDependentsChoices.' + key), - }; - }); -export const employmentStatusChoices = (t: TranslateFunction) => - ['employed', 'selfEmployed', 'notEmployed', 'retired'].map((key) => { - return { - value: key, - text: t('survey.questions.employmentStatusChoices.' + key), - }; - }); - -export const livingLocationChoices = (t: TranslateFunction) => - [ - 'westernAreaUrbanFreetown', - 'westernAreaRural', - 'easternProvince', - 'northernProvince', - 'southernProvince', - 'northWestProvince', - ].map((key) => { - return { - value: key, - text: t('survey.questions.livingLocationChoices.' + key), - }; - }); - -export const spendingChoices = (t: TranslateFunction) => - ['education', 'food', 'housing', 'healthCare', 'mobility', 'saving', 'investment'].map((key) => { +export const translateChoices = (t: TranslateFunction, choices: any[], choicesTranslationKey: String) => + choices.map((key) => { return { value: key, - text: t('survey.questions.spendingChoices.' + key), + text: t(choicesTranslationKey + '.' + key), }; });