Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src/app/toronto/vote/2026/survey/SurveyClient.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"use client";

import { useEffect, useMemo, useState, type CSSProperties } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";

Expand All @@ -19,6 +25,7 @@ import AlignmentResults, {
type SurveyRosterCandidate,
} from "./AlignmentResults";
import { submitSurvey, type SurveySubmission } from "./submitSurvey";
import { useSurveyAnalytics, type SurveyProgress } from "./analytics";

export type SurveyAnswers = Record<string, string>;

Expand Down Expand Up @@ -133,6 +140,8 @@ export default function SurveyClient({
const [done, setDone] = useState(false);
const [submission, setSubmission] = useState<SurveySubmission | null>(null);

const analytics = useSurveyAnalytics(survey);

// Everything about the shape of the form comes from the fetched survey, so a
// question added in the CMS shows up here with no change to this component.
const steps = survey.steps;
Expand All @@ -142,6 +151,30 @@ export default function SurveyClient({
const isLastStep = step === stepCount - 1;
const currentStep = steps[step];

/* Where the respondent is, for whichever event is about to be sent. Built
from the answers as they stand at the call site rather than from state
read a render later, so a step event can't report the count from before
the answer that triggered it. */
const progressAt = (
stepIndex: number,
given: SurveyAnswers = answers,
): SurveyProgress => {
const target = steps[stepIndex];
const answered = (ids: string[]) =>
ids.filter((id) => (given[id] ?? "").trim().length > 0).length;

return {
stepIndex,
stepId: target.id,
stepTitle: target.title,
answeredOnStep: answered(target.questions.map((q) => q.id)),
questionsOnStep: target.questions.length,
answeredTotal: answered(
steps.flatMap((s) => s.questions.map((q) => q.id)),
),
};
};

/* The comparison against the ward's candidates. Keyed on the ward the API
actually recorded rather than one re-derived here, so the results a
respondent reads are the results filed under their response.
Expand Down Expand Up @@ -239,7 +272,30 @@ export default function SurveyClient({
return { races, wardLabel };
}, [ward, responses, roster, mayoral, survey, answers, wardNames]);

/* What the respondent actually ended up seeing. Held until the ward's
answers have resolved one way or the other — reported the moment they are
done, since a comparison that is still loading is not yet an outcome. A
ward that never resolved has nothing to wait for. */
const reportedResults = useRef(false);
useEffect(() => {
if (!done || reportedResults.current) return;
if (ward && responses === null) return;
reportedResults.current = true;
analytics.resultsViewed({
hasComparison: Boolean(comparison),
ward: ward ?? null,
races: comparison?.races.map((race) => race.key) ?? [],
});
}, [done, ward, responses, comparison, analytics]);

const set = (id: string, value: string) => {
// First answer touched is the start of the run; the hook only lets the
// first of these through. The projected answers are for the event only —
// the state update stays functional, so two changes in one tick can't
// drop each other.
if (value.trim().length > 0) {
analytics.started(progressAt(step, { ...answers, [id]: value }));
}
setAnswers((prev) => ({ ...prev, [id]: value }));
// Clear the error as soon as they start fixing it; it comes back on Next.
setErrors((prev) => {
Expand All @@ -261,18 +317,26 @@ export default function SurveyClient({
if (message) found[question.id] = message;
}
setErrors(found);
return Object.keys(found).length === 0;
if (Object.keys(found).length > 0) {
// A step people repeatedly fail to clear looks the same in a funnel as a
// step they lose interest in; this is what tells the two apart.
analytics.stepBlocked(progressAt(step), Object.keys(found));
return false;
}
return true;
};

const next = () => {
if (!validateStep()) return;
analytics.stepCompleted(progressAt(step));
setStep((s) => Math.min(stepCount - 1, s + 1));
scrollTop();
};

const back = () => {
setErrors({});
setSubmitError(null);
analytics.stepBack(progressAt(step));
setStep((s) => Math.max(0, s - 1));
scrollTop();
};
Expand All @@ -282,11 +346,18 @@ export default function SurveyClient({
setSubmitting(true);
setSubmitError(null);
try {
setSubmission(await submitSurvey(survey, answers));
const recorded = await submitSurvey(survey, answers);
// The last step is cleared by submitting it, so it gets the same step
// event as every other one — without it the funnel's final step is
// missing and the last screen reads as a total drop.
analytics.stepCompleted(progressAt(step));
analytics.submitted(progressAt(step), recorded);
setSubmission(recorded);
setDone(true);
scrollTop();
} catch {
// Answers stay on screen so they can just press submit again.
analytics.submitFailed(progressAt(step));
setSubmitError("Something went wrong. Please try again.");
} finally {
setSubmitting(false);
Expand Down
210 changes: 210 additions & 0 deletions src/app/toronto/vote/2026/survey/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client";

import { useCallback, useEffect, useMemo, useRef } from "react";
import posthog from "posthog-js";

import { DEFAULT_ELECTION_SLUG } from "@/lib/elections/registry";
import type { Survey } from "@/lib/elections/survey";

import type { SurveySubmission } from "./submitSurvey";

/**
* PostHog instrumentation for the voter survey, kept in one place so every
* event carries the same identifying properties — which survey, which version,
* which election. A funnel built on these is only as good as that consistency:
* a step event missing `survey_version` silently pools answers to two different
* question sets into one drop-off number.
*
* The two questions this is meant to answer:
*
* completion — `survey_viewed` → `survey_started` → `survey_submitted`
* drop-off — `survey_step_completed`, filtered to `step_index` 0, 1, 2 …
* as successive funnel steps
*
* `survey_step_completed` fires once per step actually cleared, so the funnel
* reads as "reached the end of step n" rather than "was shown step n". A
* respondent who lands on step 3 and leaves without answering never emits it,
* which is the drop we want counted.
*/

/** Where someone is in the questionnaire when an event fires. */
export type SurveyProgress = {
/** zero-based, so PostHog funnel steps line up with the array */
stepIndex: number;
stepId: string;
stepTitle: string;
/** questions on this step that have a non-empty answer */
answeredOnStep: number;
questionsOnStep: number;
/** answered across the whole survey so far */
answeredTotal: number;
};

function progressProps(progress: SurveyProgress) {
return {
step_index: progress.stepIndex,
// Step number as a human reads it — the funnel is filtered on step_index,
// but a breakdown table is unreadable without this.
step_number: progress.stepIndex + 1,
step_id: progress.stepId,
step_title: progress.stepTitle,
answered_on_step: progress.answeredOnStep,
questions_on_step: progress.questionsOnStep,
answered_total: progress.answeredTotal,
};
}

export type SurveyAnalytics = {
/** First answer touched. Fires once; later calls are ignored. */
started: (progress: SurveyProgress) => void;
/** A step validated and the respondent moved on. */
stepCompleted: (progress: SurveyProgress) => void;
/** Validation held them on the step. `fields` are the question ids at fault. */
stepBlocked: (progress: SurveyProgress, fields: string[]) => void;
stepBack: (progress: SurveyProgress) => void;
submitted: (progress: SurveyProgress, submission: SurveySubmission) => void;
submitFailed: (progress: SurveyProgress) => void;
/** The comparison view rendered — or didn't, for want of a ward or answers. */
resultsViewed: (props: {
hasComparison: boolean;
ward: string | null;
races: string[];
}) => void;
};

export function useSurveyAnalytics(survey: Survey): SurveyAnalytics {
const base = useMemo(
() => ({
survey: survey.slug,
survey_version: survey.version,
election: DEFAULT_ELECTION_SLUG,
step_count: survey.steps.length,
}),
[survey],
);

// Wall-clock from the moment the questionnaire was rendered. Reported in
// seconds because nothing here is measured finely enough to justify ms.
// Stamped in the mount effect rather than here: reading the clock during
// render is impure, and the survey isn't on screen until the effect runs.
const openedAt = useRef(0);
const hasStarted = useRef(false);
/** Set once the run is accounted for — submitted, or already reported as
* abandoned — so the unload handler can't file a second ending for it. */
const settled = useRef(false);
/** Latest progress, read by the unload handler, which has no other way to
* know where they got to. */
const latest = useRef<SurveyProgress | null>(null);

const secondsElapsed = () =>
openedAt.current === 0
? 0
: Math.round((Date.now() - openedAt.current) / 1000);

const capture = useCallback(
(event: string, props: Record<string, unknown> = {}) => {
posthog.capture(event, {
...base,
seconds_elapsed: secondsElapsed(),
...props,
});
},
[base],
);

// Top of the funnel. Page views are autocaptured, but a view of *this*
// survey at *this* version is not something a URL alone says, and the
// completion rate is measured against it.
useEffect(() => {
openedAt.current = Date.now();
posthog.capture("survey_viewed", base);
}, [base]);

// The drop itself. A respondent who leaves mid-survey never sends anything
// else, so this is the only event that says how far they got before going.
// sendBeacon because the page is on its way out; visibilitychange as well as
// pagehide, since a backgrounded mobile tab may never unload.
//
// That makes it a best-effort last-seen marker rather than a verdict:
// someone who switches tabs and comes back to finish emits this *and*
// `survey_submitted`. Read completion from `survey_submitted` and drop-off
// from the absence of the next `survey_step_completed` — not from a count of
// these. At most one is sent per run, so it can't inflate on tab-switching.
useEffect(() => {
const report = () => {
if (settled.current || !hasStarted.current) return;
settled.current = true;
const progress = latest.current;
posthog.capture(
"survey_abandoned",
{
...base,
seconds_elapsed: secondsElapsed(),
...(progress ? progressProps(progress) : {}),
},
{ transport: "sendBeacon" },
);
};

const onHide = () => {
if (document.visibilityState === "hidden") report();
};

document.addEventListener("visibilitychange", onHide);
window.addEventListener("pagehide", report);
return () => {
document.removeEventListener("visibilitychange", onHide);
window.removeEventListener("pagehide", report);
};
}, [base]);

return useMemo<SurveyAnalytics>(
() => ({
started(progress) {
latest.current = progress;
if (hasStarted.current) return;
hasStarted.current = true;
capture("survey_started", progressProps(progress));
},
stepCompleted(progress) {
latest.current = progress;
capture("survey_step_completed", progressProps(progress));
},
stepBlocked(progress, fields) {
latest.current = progress;
capture("survey_step_blocked", {
...progressProps(progress),
fields,
field_count: fields.length,
});
},
stepBack(progress) {
latest.current = progress;
capture("survey_step_back", progressProps(progress));
},
submitted(progress, submission) {
latest.current = progress;
settled.current = true;
capture("survey_submitted", {
...progressProps(progress),
ward: submission.derivedRegion ?? submission.region ?? null,
});
},
submitFailed(progress) {
latest.current = progress;
capture("survey_submit_failed", progressProps(progress));
},
resultsViewed({ hasComparison, ward, races }) {
// Completing and being shown a comparison are different outcomes: a
// ward whose candidates haven't answered gets a thank-you and nothing
// to read, and that is worth being able to count.
capture("survey_results_viewed", {
has_comparison: hasComparison,
ward,
races,
});
},
}),
[capture],
);
}
10 changes: 4 additions & 6 deletions src/app/toronto/vote/2026/survey/submitSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,13 @@ export async function submitSurvey(

const data = await res.json().catch(() => ({}));

// Identify here rather than in the caller: this is where the email is known
// to have been accepted. The `survey_submitted` event itself is captured by
// useSurveyAnalytics, which also holds the step and timing properties the
// completion funnel is built on — one event, one place that emits it.
if (answers.email) {
posthog.identify(answers.email, { email: answers.email });
}
posthog.capture("survey_submitted", {
survey: survey.slug,
survey_version: survey.version,
election: DEFAULT_ELECTION_SLUG,
ward: data.derivedRegion ?? data.region ?? null,
});

return {
surveySlug: data.surveySlug ?? survey.slug,
Expand Down
Loading