From 21f59865e3eadd9befef6133effcfe57cb103119 Mon Sep 17 00:00:00 2001 From: Gavriil Date: Fri, 20 Dec 2024 20:07:26 +0100 Subject: [PATCH 1/5] Add survey responses chart page --- shared/locales/en/website-responses.json | 24 ++ shared/locales/en/website-survey.json | 4 +- shared/locales/it/website-survey.json | 4 +- shared/locales/kri/website-survey.json | 4 +- shared/src/types/question.ts | 271 ++++++++++++++++++ .../utils/stats/SurveyStatsCalculator.test.ts | 234 +++++++++++++++ .../src/utils/stats/SurveyStatsCalculator.ts | 131 +++++++++ ui/src/components/badge.tsx | 1 + .../barchart-survey-response-component.tsx | 62 ++++ .../(website)/survey/responses/card-tab.css | 3 + .../(website)/survey/responses/opacity.css | 3 + .../(website)/survey/responses/page.tsx | 117 ++++++++ .../survey/[recipient]/[survey]/questions.ts | 251 ++++++---------- 13 files changed, 935 insertions(+), 174 deletions(-) create mode 100644 shared/locales/en/website-responses.json create mode 100644 shared/src/types/question.ts create mode 100644 shared/src/utils/stats/SurveyStatsCalculator.test.ts create mode 100644 shared/src/utils/stats/SurveyStatsCalculator.ts create mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component.tsx create mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css create mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css create mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx 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..ce0ca692e --- /dev/null +++ b/shared/src/utils/stats/SurveyStatsCalculator.test.ts @@ -0,0 +1,234 @@ +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({}); +}); + +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..f8170b3b3 --- /dev/null +++ b/shared/src/utils/stats/SurveyStatsCalculator.ts @@ -0,0 +1,131 @@ +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; +} + +const SUPPORTED_SURVEY_QUESTION_TYPES = [QuestionInputType.RADIO_GROUP, QuestionInputType.CHECKBOX]; + +export class SurveyStatsCalculator { + private readonly _data: SurveyStats[]; + private readonly _aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }; + private readonly _oldestDate: Date; + + private constructor( + data: SurveyStats[], + aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }, + oldestDate: Date, + ) { + this._data = data; + this._aggregatedData = aggregatedData; + this._oldestDate = oldestDate; + } + + /** + * 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: { [key: string]: { [key: string]: SurveyAnswersByType } }; + oldestDate: Date; + } { + const aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } } = {}; + 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(aggregatedData, questionnaire, questionKey, response); + }); + } + }); + + return { typeCounts, aggregatedData, oldestDate }; + } + + private static isCompletedSurvey(survey: Survey): boolean { + return !!(survey.data && survey.questionnaire && survey.status === SurveyStatus.Completed); + } + + private static processSurveyResponse( + aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }, + questionnaire: string, + questionKey: string, + response: any, + ): void { + const question = QUESTIONS_DICTIONARY.get(questionKey); + if (!question || !SUPPORTED_SURVEY_QUESTION_TYPES.includes(question.type)) return; + + aggregatedData[questionnaire] = aggregatedData[questionnaire] || {}; + 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++; + } + + get oldestDate(): Date { + return this._oldestDate; + } + + get data(): SurveyStats[] { + return this._data; + } + + get aggregatedData(): { [key: string]: { [key: string]: SurveyAnswersByType } } { + return this._aggregatedData; + } +} 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..a42689dea --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/barchart-survey-response-component.tsx @@ -0,0 +1,62 @@ +'use client'; +import { Component } from 'react'; +import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import './opacity.css'; +export interface ChartData { + name: string; + value: number; +} + +export default class BarchartSurveyResponseComponent extends Component<{ data: ChartData[] }> { + render() { + const { data } = this.props; + 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} + /> + + + ); + } +} diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css b/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css new file mode 100644 index 000000000..d5fe7ce03 --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css @@ -0,0 +1,3 @@ +.card-tab[data-state='inactive'] .card-tab-title { + @apply text-accent-foreground; +} diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css b/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css new file mode 100644 index 000000000..8b68bc50d --- /dev/null +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css @@ -0,0 +1,3 @@ +.survey-chart .recharts-wrapper::before { + @apply to-background pointer-events-none absolute bottom-0 right-0 top-0 w-[40px] bg-gradient-to-r from-transparent content-['']; +} 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..4f2bd53e2 --- /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'; +import './card-tab.css'; + +export const revalidate = 3600; // update once an hour +export default async function Page({ params: { lang } }: DefaultPageProps) { + const surveyStatsCalculator = await SurveyStatsCalculator.build(firestoreAdmin); + const temp = surveyStatsCalculator.data; + const allSurveyData = Object.values(SurveyQuestionnaire) + .map((it) => temp.find((survey) => survey.type == it)) + .filter((it) => !!it); + const data = surveyStatsCalculator.aggregatedData; + 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: surveyStatsCalculator.oldestDate.toLocaleDateString() }, + })} + + {Object.values(SurveyQuestionnaire).map((selectedSurvey) => ( + +
+ {Object.keys(data[selectedSurvey] || []).map((key) => ( + + +
+ + {translator.t(data[selectedSurvey][key].question.translationKey)} + {data[selectedSurvey][key].question.type == QuestionInputType.CHECKBOX && ( + ({translator.t('multiple-answers')}) + )} + + + + + {data[selectedSurvey][key].total} {translator.t('answers')} + + +
+
+ {data[selectedSurvey][key].answers && ( + + )} +
+
+ ))} +
+
+ ))} +
+ +
+ ); +} 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), }; }); From bf8d50cb3dbb1324b442e6712d53469cfddee678 Mon Sep 17 00:00:00 2001 From: Gavriil Date: Fri, 20 Dec 2024 20:07:35 +0100 Subject: [PATCH 2/5] Fix questionnaires generation --- .../[recipient]/[survey]/questionnaires.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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), ]; From 6abd5279a5bb321f5a4de471c4ef15a1f3328fe3 Mon Sep 17 00:00:00 2001 From: mkue Date: Sat, 28 Dec 2024 15:44:54 +0100 Subject: [PATCH 3/5] Various small cleanups --- .../utils/stats/SurveyStatsCalculator.test.ts | 7 +- .../src/utils/stats/SurveyStatsCalculator.ts | 52 +++++----- .../barchart-survey-response-component.tsx | 99 +++++++++---------- .../(website)/survey/responses/card-tab.css | 3 - .../(website)/survey/responses/opacity.css | 3 - .../(website)/survey/responses/page.tsx | 52 +++++----- 6 files changed, 108 insertions(+), 108 deletions(-) delete mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css delete mode 100644 website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css diff --git a/shared/src/utils/stats/SurveyStatsCalculator.test.ts b/shared/src/utils/stats/SurveyStatsCalculator.test.ts index ce0ca692e..7bd039531 100644 --- a/shared/src/utils/stats/SurveyStatsCalculator.test.ts +++ b/shared/src/utils/stats/SurveyStatsCalculator.test.ts @@ -79,7 +79,12 @@ 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({}); + expect(emptyCalculator.aggregatedData).toEqual({ + [SurveyQuestionnaire.Checkin]: {}, + [SurveyQuestionnaire.Onboarding]: {}, + [SurveyQuestionnaire.OffboardedCheckin]: {}, + [SurveyQuestionnaire.Offboarding]: {}, + }); }); const surveyRecords = [ diff --git a/shared/src/utils/stats/SurveyStatsCalculator.ts b/shared/src/utils/stats/SurveyStatsCalculator.ts index f8170b3b3..0f5463168 100644 --- a/shared/src/utils/stats/SurveyStatsCalculator.ts +++ b/shared/src/utils/stats/SurveyStatsCalculator.ts @@ -14,23 +14,33 @@ export interface SurveyAnswersByType { 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: { [key: string]: { [key: string]: SurveyAnswersByType } }; + private readonly _aggregatedData: AggregatedSurveyData; private readonly _oldestDate: Date; - private constructor( - data: SurveyStats[], - aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }, - 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 @@ -61,10 +71,15 @@ export class SurveyStatsCalculator { private static aggregateSurveyData(surveysData: Survey[]): { typeCounts: { [type: string]: number }; - aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }; + aggregatedData: AggregatedSurveyData; oldestDate: Date; } { - const aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } } = {}; + const aggregatedSurveyData: AggregatedSurveyData = { + [SurveyQuestionnaire.Checkin]: {}, + [SurveyQuestionnaire.Onboarding]: {}, + [SurveyQuestionnaire.OffboardedCheckin]: {}, + [SurveyQuestionnaire.Offboarding]: {}, + }; const typeCounts: { [type: string]: number } = {}; let oldestDate = new Date(); @@ -77,12 +92,12 @@ export class SurveyStatsCalculator { const questionnaire = survey.questionnaire!; typeCounts[questionnaire] = (typeCounts[questionnaire] || 0) + 1; Object.entries(survey.data!).forEach(([questionKey, response]) => { - this.processSurveyResponse(aggregatedData, questionnaire, questionKey, response); + this.processSurveyResponse(aggregatedSurveyData, questionnaire, questionKey, response); }); } }); - return { typeCounts, aggregatedData, oldestDate }; + return { typeCounts, aggregatedData: aggregatedSurveyData, oldestDate }; } private static isCompletedSurvey(survey: Survey): boolean { @@ -90,15 +105,14 @@ export class SurveyStatsCalculator { } private static processSurveyResponse( - aggregatedData: { [key: string]: { [key: string]: SurveyAnswersByType } }, - questionnaire: string, + aggregatedData: { [key in SurveyQuestionnaire]: { [key: string]: SurveyAnswersByType } }, + 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] = aggregatedData[questionnaire] || {}; aggregatedData[questionnaire][questionKey] = aggregatedData[questionnaire][questionKey] || { answers: {}, total: 0, @@ -116,16 +130,4 @@ export class SurveyStatsCalculator { } questionData.total++; } - - get oldestDate(): Date { - return this._oldestDate; - } - - get data(): SurveyStats[] { - return this._data; - } - - get aggregatedData(): { [key: string]: { [key: string]: SurveyAnswersByType } } { - return this._aggregatedData; - } } 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 index a42689dea..2adf69c9c 100644 --- 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 @@ -1,62 +1,61 @@ 'use client'; -import { Component } from 'react'; import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts'; -import './opacity.css'; + export interface ChartData { name: string; value: number; } -export default class BarchartSurveyResponseComponent extends Component<{ data: ChartData[] }> { - render() { - const { data } = this.props; - const barHeight = 30; - const chartHeight = data.length * barHeight + 40; - - let fillColor = 'hsl(var(--foreground))'; +function BarchartSurveyResponseComponent({ data }: { data: ChartData[] }) { + const barHeight = 30; + const chartHeight = data.length * barHeight + 40; - const customLabel = (props: any) => { - const { x, y, width, value } = props; - return ( - - {value} - {value} - - ); - }; + let fillColor = 'hsl(var(--foreground))'; + const customLabel = (props: any) => { + const { x, y, width, value } = props; return ( - - - - - - - `${value}%`} - minTickGap={1} - /> - - + + {value} + {value} + ); - } + }; + + return ( + + + + + + + `${value}%`} + minTickGap={1} + /> + + + ); } + +export default BarchartSurveyResponseComponent; diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css b/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css deleted file mode 100644 index d5fe7ce03..000000000 --- a/website/src/app/[lang]/[region]/(website)/survey/responses/card-tab.css +++ /dev/null @@ -1,3 +0,0 @@ -.card-tab[data-state='inactive'] .card-tab-title { - @apply text-accent-foreground; -} diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css b/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css deleted file mode 100644 index 8b68bc50d..000000000 --- a/website/src/app/[lang]/[region]/(website)/survey/responses/opacity.css +++ /dev/null @@ -1,3 +0,0 @@ -.survey-chart .recharts-wrapper::before { - @apply to-background pointer-events-none absolute bottom-0 right-0 top-0 w-[40px] bg-gradient-to-r from-transparent content-['']; -} diff --git a/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx b/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx index 4f2bd53e2..297ebd21c 100644 --- a/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/survey/responses/page.tsx @@ -19,16 +19,16 @@ import { Typography, } from '@socialincome/ui'; import { Fragment } from 'react'; -import './card-tab.css'; export const revalidate = 3600; // update once an hour + export default async function Page({ params: { lang } }: DefaultPageProps) { - const surveyStatsCalculator = await SurveyStatsCalculator.build(firestoreAdmin); - const temp = surveyStatsCalculator.data; + 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 data = surveyStatsCalculator.aggregatedData; + const translator = await Translator.getInstance({ language: lang, namespaces: ['website-responses', 'website-survey'], @@ -57,13 +57,13 @@ export default async function Page({ params: { lang } }: DefaultPageProps) { {allSurveyData.map( (surveyData) => surveyData && ( - - - - {translator.t(surveyData.type + '.title')} + + + + {translator.t(`${surveyData.type}.title`)} - - {translator.t(surveyData.type + '.description')} + + {translator.t(`${surveyData.type}.description`)} {surveyData.total} {translator.t('data-points')} @@ -75,34 +75,34 @@ export default async function Page({ params: { lang } }: DefaultPageProps) { {translator.t('responses-since', { - context: { sinceDate: surveyStatsCalculator.oldestDate.toLocaleDateString() }, + context: { sinceDate: oldestDate.toLocaleDateString() }, })} + {Object.values(SurveyQuestionnaire).map((selectedSurvey) => (
- {Object.keys(data[selectedSurvey] || []).map((key) => ( - - -
- - {translator.t(data[selectedSurvey][key].question.translationKey)} - {data[selectedSurvey][key].question.type == QuestionInputType.CHECKBOX && ( - ({translator.t('multiple-answers')}) + {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][key].total} {translator.t('answers')} + {data[selectedSurvey][questionKey].total} {translator.t('answers')}
-
- {data[selectedSurvey][key].answers && ( +
+ {data[selectedSurvey][questionKey].answers && ( + data={convertToBarchartData(data[selectedSurvey][questionKey])} + /> )}
@@ -111,7 +111,7 @@ export default async function Page({ params: { lang } }: DefaultPageProps) { ))} - + ); } From d94d4288a919720a755a109d62178c15425b0d38 Mon Sep 17 00:00:00 2001 From: mkue Date: Sat, 28 Dec 2024 15:48:14 +0100 Subject: [PATCH 4/5] Various small cleanups --- shared/src/utils/stats/SurveyStatsCalculator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/utils/stats/SurveyStatsCalculator.ts b/shared/src/utils/stats/SurveyStatsCalculator.ts index 0f5463168..aa03938e0 100644 --- a/shared/src/utils/stats/SurveyStatsCalculator.ts +++ b/shared/src/utils/stats/SurveyStatsCalculator.ts @@ -105,7 +105,7 @@ export class SurveyStatsCalculator { } private static processSurveyResponse( - aggregatedData: { [key in SurveyQuestionnaire]: { [key: string]: SurveyAnswersByType } }, + aggregatedData: AggregatedSurveyData, questionnaire: SurveyQuestionnaire, questionKey: string, response: any, From e0421f69e432112e6134045dc63ea2c9f0d70285 Mon Sep 17 00:00:00 2001 From: Gavriil Date: Sat, 28 Dec 2024 16:58:17 +0100 Subject: [PATCH 5/5] !fixup --- .../survey/responses/barchart-survey-response-component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2adf69c9c..778da4ae6 100644 --- 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 @@ -33,7 +33,7 @@ function BarchartSurveyResponseComponent({ data }: { data: ChartData[] }) {