From 21ff1eb3853c060d4b0ca678ade63be37dbaf84f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Nov 2025 08:43:56 +0000 Subject: [PATCH] feat: Add NPS and Rating summary view improvements This commit introduces several enhancements to the NPS and Rating survey summary views. **NPS Summary:** - Implemented a tabbed interface to switch between "grouped" and "individual" views. - The "grouped" view displays the breakdown of promoters, passives, and detractors. - The "individual" view shows a bar chart of responses for each NPS score (0-10). - Added tooltips for better user guidance. **Rating Summary:** - Integrated CSAT (Customer Satisfaction) score calculation and display. - Added tooltips for CSAT information. **Backend/Logic Changes:** - Updated `getQuestionSummary` to calculate and include CSAT data for Rating questions. - Modified `getQuestionSummary` to include an array of `choices` for NPS questions, detailing individual score counts and percentages. - Added new tests to cover CSAT calculations for various rating scales and NPS individual score breakdowns. - Updated type definitions for `TSurveyQuestionSummaryRating` and `TSurveyQuestionSummaryNps` to accommodate the new data structures. Co-authored-by: johannes --- .../summary/components/NPSSummary.tsx | 130 ++++-- .../summary/components/RatingSummary.tsx | 28 +- .../summary/components/SatisfactionSmiley.tsx | 149 +++++++ .../summary/lib/surveySummary.test.ts | 401 ++++++++++++++++++ .../(analysis)/summary/lib/surveySummary.ts | 39 ++ packages/types/surveys/types.ts | 11 + 6 files changed, 726 insertions(+), 32 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionSmiley.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index f4853f8b21..698afb8d02 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -1,5 +1,7 @@ "use client"; +import { BarChart, BarChartHorizontal } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { TI18nString, @@ -9,8 +11,11 @@ import { TSurveyQuestionTypeEnum, } from "@formbricks/types/surveys/types"; import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { convertFloatToNDecimal } from "../lib/utils"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; +import { SatisfactionSmiley } from "./SatisfactionSmiley"; interface NPSSummaryProps { questionSummary: TSurveyQuestionSummaryNps; @@ -26,6 +31,8 @@ interface NPSSummaryProps { export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped"); + const applyFilter = (group: string) => { const filters = { promoters: { @@ -62,38 +69,105 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro return (
-
- {["promoters", "passives", "detractors", "dismissed"].map((group) => ( - - ))} + + + {t("environments.surveys.summary.promotersTooltip")} + + +
+ setActiveTab(value as "grouped" | "individual")}> +
+ + }> + {t("environments.surveys.summary.grouped")} + + }> + {t("environments.surveys.summary.individual")} + + +
+ + +
+ {["promoters", "passives", "detractors", "dismissed"].map((group) => ( + + ))} +
+
+ + +
+ {questionSummary.choices.map((choice) => ( + + ))} +
+
+
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index f0d94e7505..691706ebaa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -13,7 +13,9 @@ import { import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { RatingResponse } from "@/modules/ui/components/rating-response"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; +import { SatisfactionSmiley } from "./SatisfactionSmiley"; interface RatingSummaryProps { questionSummary: TSurveyQuestionSummaryRating; @@ -42,11 +44,29 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm questionSummary={questionSummary} survey={survey} additionalInfo={ -
- {getIconBasedOnScale} -
- {t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)} +
+
+ {getIconBasedOnScale} +
+ {t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)} +
+ + + +
+ +
+ CSAT: {questionSummary.csat.satisfiedPercentage}%{" "} + {t("environments.surveys.summary.satisfied")} +
+
+
+ + {t("environments.surveys.summary.csatTooltip")} + +
+
} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionSmiley.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionSmiley.tsx new file mode 100644 index 0000000000..13b6057754 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionSmiley.tsx @@ -0,0 +1,149 @@ +interface SatisfactionSmileyProps { + percentage: number; + className?: string; +} + +const TiredFace = ({ className }: { className?: string }) => ( + + + + + + + + + + +); + +const SlightlySmilingFace = ({ className }: { className?: string }) => ( + + + + + + + + +); + +const GrinningSquintingFace = ({ className }: { className?: string }) => ( + + + + + + + + + + +); + +export const SatisfactionSmiley = ({ percentage, className }: SatisfactionSmileyProps) => { + if (percentage > 80) { + return ; + } else if (percentage >= 55) { + return ; + } else { + return ; + } +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts index ff95678b15..acb8acc83b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts @@ -2334,6 +2334,98 @@ describe("NPS question type tests", () => { // Score should be -100 since all valid responses are detractors expect(summary[0].score).toBe(-100); }); + + test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "nps-q1": 0 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "nps-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "nps-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r4", data: { "nps-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r5", data: { "nps-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary[0].choices).toBeDefined(); + expect(summary[0].choices).toHaveLength(11); // Scores 0-10 + + // Verify specific scores + const score0 = summary[0].choices.find((c: any) => c.rating === 0); + expect(score0.count).toBe(1); + expect(score0.percentage).toBe(20); // 1/5 * 100 + + const score5 = summary[0].choices.find((c: any) => c.rating === 5); + expect(score5.count).toBe(1); + expect(score5.percentage).toBe(20); + + const score7 = summary[0].choices.find((c: any) => c.rating === 7); + expect(score7.count).toBe(1); + expect(score7.percentage).toBe(20); + + const score9 = summary[0].choices.find((c: any) => c.rating === 9); + expect(score9.count).toBe(1); + expect(score9.percentage).toBe(20); + + const score10 = summary[0].choices.find((c: any) => c.rating === 10); + expect(score10.count).toBe(1); + expect(score10.percentage).toBe(20); + + // Verify scores with no responses have 0 count + const score1 = summary[0].choices.find((c: any) => c.rating === 1); + expect(score1.count).toBe(0); + expect(score1.percentage).toBe(0); + }); + + test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses: any[] = []; + + const dropOff = [{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary[0].choices).toBeDefined(); + expect(summary[0].choices).toHaveLength(11); // Scores 0-10 + + // All scores should have 0 count and percentage + summary[0].choices.forEach((choice: any) => { + expect(choice.count).toBe(0); + expect(choice.percentage).toBe(0); + }); + }); }); describe("Rating question type tests", () => { @@ -2557,6 +2649,315 @@ describe("Rating question type tests", () => { // Verify dismissed is 0 expect(summary[0].dismissed.count).toBe(0); }); + + test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 3, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 3: satisfied = score 3 + // 2 out of 3 responses are satisfied (score 3) + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100) + }); + + test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 4, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 4: satisfied = scores 3-4 + // 2 out of 3 responses are satisfied (scores 3 and 4) + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(67); + }); + + test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 5: satisfied = scores 4-5 + // 2 out of 3 responses are satisfied (scores 4 and 5) + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(67); + }); + + test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 6, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 6: satisfied = scores 5-6 + // 2 out of 3 responses are satisfied (scores 5 and 6) + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(67); + }); + + test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 7, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 7: satisfied = scores 6-7 + // 2 out of 3 responses are satisfied (scores 6 and 7) + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(67); + }); + + test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 10, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 8 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r4", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 10: satisfied = scores 8-10 + // 3 out of 4 responses are satisfied (scores 8, 9, 10) + expect(summary[0].csat.satisfiedCount).toBe(3); + expect(summary[0].csat.satisfiedPercentage).toBe(75); + }); + + test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 5: satisfied = scores 4-5 + // All 2 responses are satisfied + expect(summary[0].csat.satisfiedCount).toBe(2); + expect(summary[0].csat.satisfiedPercentage).toBe(100); + }); + + test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { id: "r1", data: { "rating-q1": 1 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + { id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true }, + ]; + + const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Range 5: satisfied = scores 4-5 + // None of the responses are satisfied (all are 1, 2, or 3) + expect(summary[0].csat.satisfiedCount).toBe(0); + expect(summary[0].csat.satisfiedPercentage).toBe(0); + }); + + test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate our service" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses: any[] = []; + + const dropOff = [{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary[0].csat.satisfiedCount).toBe(0); + expect(summary[0].csat.satisfiedPercentage).toBe(0); + }); }); describe("PictureSelection question type tests", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index d0a3f2d294..4ee08f44e7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -539,6 +539,26 @@ export const getQuestionSummary = async ( }); }); + // Calculate CSAT based on range + let satisfiedCount = 0; + const range = question.range; + if (range === 3) { + satisfiedCount = choiceCountMap[3] || 0; + } else if (range === 4) { + satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0); + } else if (range === 5) { + satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0); + } else if (range === 6) { + satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0); + } else if (range === 7) { + satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0); + } else if (range === 10) { + satisfiedCount = + (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0); + } + const satisfiedPercentage = + totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0; + summary.push({ type: question.type, question, @@ -548,6 +568,10 @@ export const getQuestionSummary = async ( dismissed: { count: dismissed, }, + csat: { + satisfiedCount, + satisfiedPercentage, + }, }); values = []; @@ -563,10 +587,17 @@ export const getQuestionSummary = async ( score: 0, }; + // Track individual score counts (0-10) + const scoreCountMap: Record = {}; + for (let i = 0; i <= 10; i++) { + scoreCountMap[i] = 0; + } + responses.forEach((response) => { const value = response.data[question.id]; if (typeof value === "number") { data.total++; + scoreCountMap[value]++; if (value >= 9) { data.promoters++; } else if (value >= 7) { @@ -585,6 +616,13 @@ export const getQuestionSummary = async ( ? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100) : 0; + // Build choices array with individual score breakdown + const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({ + rating: parseInt(rating), + count, + percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0, + })); + summary.push({ type: question.type, question, @@ -607,6 +645,7 @@ export const getQuestionSummary = async ( count: data.dismissed, percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0, }, + choices, }); break; } diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 8313fcdc51..35954c5765 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -2587,6 +2587,10 @@ export const ZSurveyQuestionSummaryRating = z.object({ dismissed: z.object({ count: z.number(), }), + csat: z.object({ + satisfiedCount: z.number(), + satisfiedPercentage: z.number(), + }), }); export type TSurveyQuestionSummaryRating = z.infer; @@ -2613,6 +2617,13 @@ export const ZSurveyQuestionSummaryNps = z.object({ count: z.number(), percentage: z.number(), }), + choices: z.array( + z.object({ + rating: z.number(), + count: z.number(), + percentage: z.number(), + }) + ), }); export type TSurveyQuestionSummaryNps = z.infer;