Skip to content

Commit

Permalink
feat: Time to complete Metadata (formbricks#1416)
Browse files Browse the repository at this point in the history
Co-authored-by: Johannes <[email protected]>
Co-authored-by: Matthias Nannt <[email protected]>
  • Loading branch information
3 people authored Nov 30, 2023
1 parent 05884ea commit 2118f88
Show file tree
Hide file tree
Showing 29 changed files with 478 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => (
<div className="relative m-2 rounded-lg bg-slate-200">
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl as string} key={index} download target="_blank">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { evaluateCondition } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { useMemo } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { TimerIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";

interface SummaryDropOffsProps {
survey: TSurvey;
Expand All @@ -10,12 +12,45 @@ interface SummaryDropOffsProps {
}

export default function SummaryDropOffs({ responses, survey, displayCount }: SummaryDropOffsProps) {
const getDropoff = () => {
const initialAvgTtc = useMemo(
() =>
survey.questions.reduce((acc, question) => {
acc[question.id] = 0;
return acc;
}, {}),
[survey.questions]
);

const [avgTtc, setAvgTtc] = useState(initialAvgTtc);

interface DropoffMetricsType {
dropoffCount: number[];
viewsCount: number[];
dropoffPercentage: number[];
}
const [dropoffMetrics, setDropoffMetrics] = useState<DropoffMetricsType>({
dropoffCount: [],
viewsCount: [],
dropoffPercentage: [],
});

const calculateMetrics = useCallback(() => {
let totalTtc = { ...initialAvgTtc };
let responseCounts = { ...initialAvgTtc };

let dropoffArr = new Array(survey.questions.length).fill(0);
let viewsArr = new Array(survey.questions.length).fill(0);
let dropoffPercentageArr = new Array(survey.questions.length).fill(0);

responses.forEach((response) => {
// Calculate total time-to-completion
Object.keys(avgTtc).forEach((questionId) => {
if (response.ttc && response.ttc[questionId]) {
totalTtc[questionId] += response.ttc[questionId];
responseCounts[questionId]++;
}
});

let currQuesIdx = 0;

while (currQuesIdx < survey.questions.length) {
Expand Down Expand Up @@ -84,35 +119,68 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum
}
});

// Calculate the average time for each question
Object.keys(totalTtc).forEach((questionId) => {
totalTtc[questionId] =
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
});

// Calculate drop-off percentages
dropoffPercentageArr[0] = (dropoffArr[0] / displayCount) * 100 || 0;
for (let i = 1; i < survey.questions.length; i++) {
if (viewsArr[i - 1] !== 0) {
dropoffPercentageArr[i] = (dropoffArr[i] / viewsArr[i - 1]) * 100;
}
}

return [dropoffArr, viewsArr, dropoffPercentageArr];
};
return {
newAvgTtc: totalTtc,
dropoffCount: dropoffArr,
viewsCount: viewsArr,
dropoffPercentage: dropoffPercentageArr,
};
}, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc]);

const [dropoffCount, viewsCount, dropoffPercentage] = useMemo(() => getDropoff(), [responses]);
useEffect(() => {
const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics();
setAvgTtc(newAvgTtc);
setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage });
}, [responses]);

return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-5 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">Questions</div>
<div className="pl-4 text-center md:pl-6">Views</div>
<div className="px-4 text-center md:px-6">Drop-off</div>
<div className="flex justify-center">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<TimerIcon className="h-5 w-5" />
</TooltipTrigger>
<TooltipContent side={"top"}>
<p className="text-center font-normal">Average time to complete each question.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="px-4 text-center md:px-6">Views</div>
<div className="pr-6 text-center md:pl-6">Drop Offs</div>
</div>
{survey.questions.map((question, i) => (
<div
key={question.id}
className="grid grid-cols-5 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 pl-4 md:pl-6">{question.headline}</div>
<div className="whitespace-pre-wrap pl-6 text-center font-semibold">{viewsCount[i]}</div>
<div className="px-4 text-center md:px-6">
<span className="font-semibold">{dropoffCount[i]} </span>
<span>({Math.round(dropoffPercentage[i])}%)</span>
<div className="whitespace-pre-wrap text-center font-semibold">
{avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{dropoffMetrics.viewsCount[i]}
</div>
<div className=" pl-6 text-center md:px-6">
<span className="font-semibold">{dropoffMetrics.dropoffCount[i]} </span>
<span>({Math.round(dropoffMetrics.dropoffPercentage[i])}%)</span>
</div>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { timeSinceConditionally } from "@formbricks/lib/time";
import { Button } from "@formbricks/ui/Button";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid";
import { useMemo, useState } from "react";

interface SummaryMetadataProps {
responses: TResponse[];
Expand Down Expand Up @@ -34,20 +35,52 @@ const StatCard = ({ label, percentage, value, tooltipText }) => (
</TooltipProvider>
);

function formatTime(ttc, totalResponses) {
const seconds = ttc / (1000 * totalResponses);
let formattedValue;

if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
formattedValue = `${minutes}m ${remainingSeconds.toFixed(2)}s`;
} else {
formattedValue = `${seconds.toFixed(2)}s`;
}

return formattedValue;
}

export default function SummaryMetadata({
responses,
survey,
displayCount,
setShowDropOffs,
showDropOffs,
}: SummaryMetadataProps) {
const completedResponses = responses.filter((r) => r.finished).length;
const completedResponsesCount = useMemo(() => responses.filter((r) => r.finished).length, [responses]);
const [validTtcResponsesCount, setValidResponsesCount] = useState(0);

const ttc = useMemo(() => {
let validTtcResponsesCountAcc = 0; //stores the count of responses that contains a _total value
const ttc = responses.reduce((acc, response) => {
if (response.ttc._total) {
validTtcResponsesCountAcc++;
return acc + response.ttc._total;
}
return acc;
}, 0);
setValidResponsesCount(validTtcResponsesCountAcc);
return ttc;
}, [responses]);

console.log(ttc);

const totalResponses = responses.length;

return (
<div className="mb-4">
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-3 lg:gap-x-2">
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-2">
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm text-slate-600">Displays</p>
<p className="text-2xl font-bold text-slate-800">
Expand All @@ -62,16 +95,24 @@ export default function SummaryMetadata({
/>
<StatCard
label="Responses"
percentage={`${Math.round((completedResponses / displayCount) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : completedResponses}
percentage={`${Math.round((completedResponsesCount / displayCount) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : completedResponsesCount}
tooltipText="People who completed the survey."
/>
<StatCard
label="Drop Offs"
percentage={`${Math.round(((totalResponses - completedResponses) / totalResponses) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : totalResponses - completedResponses}
percentage={`${Math.round(((totalResponses - completedResponsesCount) / totalResponses) * 100)}%`}
value={responses.length === 0 ? <span>-</span> : totalResponses - completedResponsesCount}
tooltipText="People who started but not completed the survey."
/>
<StatCard
label="Time to Complete"
percentage={null}
value={
validTtcResponsesCount === 0 ? <span>-</span> : `${formatTime(ttc, validTtcResponsesCount)}`
}
tooltipText="Average time to complete the survey."
/>
</div>
<div className="flex flex-col justify-between gap-2 lg:col-span-1">
<div className="text-right text-xs text-slate-400">
Expand Down
4 changes: 3 additions & 1 deletion apps/web/app/lib/formbricks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { env } from "@formbricks/lib/env.mjs";

export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
const ttc = { onboarding: 0 };

export const createResponse = async (
surveyId: string,
Expand All @@ -11,12 +12,12 @@ export const createResponse = async (
finished: boolean = false
): Promise<any> => {
const api = formbricks.getApi();

return await api.client.response.create({
surveyId,
userId,
finished,
data,
ttc,
});
};

Expand All @@ -30,6 +31,7 @@ export const updateResponse = async (
responseId,
finished,
data,
ttc,
});
};

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/s/[surveyId]/components/LinkSurvey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export default function LinkSurvey({
...responseUpdate.data,
...hiddenFieldsRecord,
},
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
meta: {
url: window.location.href,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
surveyId: true,
finished: true,
data: true,
ttc: true,
meta: true,
personAttributes: true,
singleUseId: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
surveyId: true,
finished: true,
data: true,
ttc: true,
meta: true,
personAttributes: true,
singleUseId: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/api/client/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ export class ResponseAPI {
responseId,
finished,
data,
ttc,
}: TResponseUpdateInputWithResponseId): Promise<Result<{}, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
data,
ttc,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Response" ADD COLUMN "ttc" JSONB NOT NULL DEFAULT '{}';
3 changes: 3 additions & 0 deletions packages/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ model Response {
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
data Json @default("{}")
/// @zod.custom(imports.ZResponseTtc)
/// [ResponseTtc]
ttc Json @default("{}")
/// @zod.custom(imports.ZResponseMeta)
/// [ResponseMeta]
meta Json @default("{}")
Expand Down
7 changes: 6 additions & 1 deletion packages/database/zod-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
export { ZIntegrationConfig } from "@formbricks/types/integration";

export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/responses";
export {
ZResponseData,
ZResponsePersonAttributes,
ZResponseMeta,
ZResponseTtc,
} from "@formbricks/types/responses";

export {
ZSurveyWelcomeCard,
Expand Down
1 change: 1 addition & 0 deletions packages/js/src/lib/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const renderWidget = (survey: TSurvey) => {
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
});
},
Expand Down
Loading

0 comments on commit 2118f88

Please sign in to comment.