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
7 changes: 7 additions & 0 deletions .factory/oracles/107.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"issue": 107,
"files": [
"apps/zoom-app/src/components/LiveTab.test.jsx",
"apps/web/src/components/LiveTab.test.jsx"
]
}
98 changes: 98 additions & 0 deletions .factory/plans/107-timer-reach-audience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Timer reach metric: attach audience_size to timer_started events (#107)

## Problem
`timer_started` counts operator presses, not people served: a class of 20 where the teacher
presses START 15 times looks like 15 solo web starts. Analytics (`traffic_report.py`, the
PostHog "Timer reach (daily)" insight) is already waiting for an `audience_size` property on
`timer_started` from the Zoom app. Only a count leaves the device, never names.

## Findings
- `apps/zoom-app/src/components/LiveTab.jsx:601` `handleStart`; the event is sent at
`:634-642` inside `(window.requestIdleCallback || setTimeout)(...)` with `speaker_name`,
`role`, `timing_rules`. It is the only `timer_started` in the Zoom app (`handleContinue`
at `:645` sends `timer_continued`).
- `apps/zoom-app/src/utils/zoomSdk.js:4444` `getZoomParticipants()` awaits SDK init, then
merges `getUserContext` (self) with `getMeetingParticipants` (everyone else), deduped. It
returns `{ participants, role, restricted }`; with no SDK it returns `participants: []`,
`restricted: false` (`:4452-4455`). `restricted` is true only when the list call failed
and the role is known and not host/co-host (`:4498-4499`). It does not throw on API
errors; `initializeZoomSdk()` is the only await that could.
- `apps/zoom-app/src/hooks/useZoomParticipants.js:18` fetches once per mount and never
refreshes. `LiveTab` mounts when the app opens, so a hook count misses late joiners (the
owner's comment on the issue). The hook is not used.
- `zoom_meeting_id` is already registered on every event as a session property
(`apps/zoom-app/src/main.jsx:101-105`), which is what the report groups reach by. No change.
- Web timer: `apps/web/src/components/LiveTab.jsx:203` sends `{ role, speaker_name }` and has
no Zoom SDK. No change needed there; AC-4 only pins it down.
- Tests: `apps/zoom-app/src/components/LiveTab.test.jsx` already mocks `getZoomParticipants`
(`:42`) and `trackEvent` (`:47`) and uses fake timers (`:87`); nothing asserts on
`timer_started` yet. `apps/web/src/components/LiveTab.test.jsx` exists.
- Zoom-client spec `e2e/zoom-client/timer-core.md` covers `LiveTab.jsx` and `zoomSdk.js`;
TC-10 already checks a `timer_started` in PostHog, so there is precedent for event evidence.
`timer-sounds.md` also lists `LiveTab.jsx`; its steps don't involve the event.

## Approach
In `handleStart`, keep `startTimer()` synchronous and unchanged. In the idle callback, call
`getZoomParticipants()`, then send `timer_started` with the existing properties plus
`audience_size: participants.length` only when `!restricted && participants.length > 0`.
If the fetch rejects, send the event without the property (`.catch` → empty result), so a
failed participant read never loses a start. Capture `speakerName`/`selectedRole`/`rules`
before the await (they already are, by closure). About 10 lines in one file.

Rejected:
- `useZoomParticipants()` in `LiveTab` (the issue's original diff): count frozen at app open,
and a third mount-time fetch.
- Polling or subscribing to participant changes: more SDK traffic for a number only needed
at START.
- Sending `audience_size: 0` or `1` when withheld: the issue wants it omitted, and analytics
already treats missing as 1.
- Adding it to `speech_finished` / `timer_continued`: out of scope for the issue.

## Acceptance criteria
- AC-1: Given the app runs in a Zoom meeting as host or co-host and `getZoomParticipants`
reports N ≥ 1 participants with `restricted: false`, when the organizer presses START,
then `timer_started` carries `audience_size: N` alongside the unchanged `speaker_name`,
`role` and `timing_rules`.
- AC-2: Given the participant count changes after the app opened (people join), when START
is pressed, then `audience_size` is the count read at START, not at mount.
- AC-3: Given the organizer is neither host nor co-host (`restricted: true`), when START is
pressed, then `timer_started` is sent without `audience_size`.
- AC-4: Given no Zoom SDK (the Zoom app in a plain browser, or the web timer at
`apps/web`), when START is pressed, then `timer_started` is sent without `audience_size`.
- AC-5: Given `getZoomParticipants` rejects, when START is pressed, then the timer still
starts immediately and `timer_started` is still sent, without `audience_size`. *(proposed)*
- AC-6: Given the build is released to production, the PostHog "Timer reach (daily)"
insight shows reach above starts on days with multi-person meetings. *(post-release;
not checked by this PR)*

## Test list
| AC | Unit | Playwright | Zoom-client spec |
|---|---|---|---|
| AC-1 | `apps/zoom-app/src/components/LiveTab.test.jsx`: mock 3 participants, START, run idle timer, `trackEvent` called with `timer_started` incl. `audience_size: 3` and the old props | — | `timer-core` NEW TC-11 (auto): host presses START; PostHog `timer_started` has `audience_size` equal to Zoom's Participants count |
| AC-2 | same file: mock returns 1 participant first, 4 after render; START → `audience_size: 4`, and `getZoomParticipants` called after the press | — | `timer-core` TC-11 (open the app first, then a second participant joins if available; else note it) |
| AC-3 | same file: `restricted: true` with self only → no `audience_size` key | — | `timer-core` NEW TC-12 (human): second Zoom account, not host/co-host, presses START; event has no `audience_size` |
| AC-4 | same file: `participants: []` → no key. `apps/web/src/components/LiveTab.test.jsx`: `timer_started` props have no `audience_size` | existing `e2e/zoom.timer-flow.spec.js` START flow keeps passing (no SDK path) | — |
| AC-5 | same file: `getZoomParticipants` rejects → `timer_started` still sent without the key; timer running right after the click | — | — |
| AC-6 | — | — | — (post-release; listed under "Not verified" in the PR) |

Spec edit: add TC-11 and TC-12 to `e2e/zoom-client/timer-core.md` (no renumbering), update
the INDEX row's "Covers" text, run `npm run test:specs`.

## Risk
Low. Analytics-only change in `apps/zoom-app/src/components/LiveTab.jsx`; START itself is
not touched, and the event path catches its own failure. One extra
`getUserContext`/`getMeetingParticipants` pair per START (a non-host gets a logged warning
per START). No `config.json` `riskHigh` paths.

## Open questions
- Q1: The acceptance criteria are the issue's, rewritten as Given/When/Then; AC-2 and AC-5
are new (AC-2 from the owner's comment, AC-5 proposed). Default: keep all six.
- Q2: Does deriving a participant count from `getMeetingParticipants` need a line in the
privacy policy or the Marketplace data-use answers? Only a number leaves the device.
Default: no policy change in this PR; flag it in the PR's "Not verified" for the human.
- Q3: The dev test meeting usually has only the host, so TC-11 would see `audience_size: 1`.
Default: accept 1 as proof the property is attached (it matches Zoom's own count); AC-2 is
proven by the unit test, and TC-11 checks a larger count only if a second participant is
present.
- Q4: A non-host gets one "Participant list needs host or co-host" warning in the debug log
per START. Default: accept it; don't add a cache or role pre-check.
19 changes: 19 additions & 0 deletions apps/web/src/components/LiveTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import LiveTab from './LiveTab';
import { renderWithProviders } from '../test/helpers';
import { useEffect } from 'react';
import { useTimer } from '../context/TimerContext';
import { trackEvent } from '../utils/posthog';

describe('LiveTab', () => {
describe('initial state', () => {
Expand Down Expand Up @@ -96,6 +97,24 @@ describe('LiveTab', () => {
expect(onTimerStart).toHaveBeenCalledTimes(1);
});
});

// #107: the web timer has no Zoom meeting to count, so reach falls back to
// one per start. A guessed audience_size here would inflate it.
it('sends timer_started without audience_size', async () => {
const user = userEvent.setup();
trackEvent.mockClear();
renderWithProviders(<LiveTab />);

await user.click(await screen.findByRole('button', { name: /start/i }));

await waitFor(() => {
expect(trackEvent).toHaveBeenCalledWith('timer_started', expect.anything());
});
const calls = trackEvent.mock.calls.filter(([name]) => name === 'timer_started');
expect(calls).toHaveLength(1);
expect(calls[0][1]).toMatchObject({ role: 'Standard Speech' });
expect(calls[0][1]).not.toHaveProperty('audience_size');
});
});

describe('stopping the timer', () => {
Expand Down
29 changes: 18 additions & 11 deletions apps/zoom-app/src/components/LiveTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const CardImagesModal = lazy(() => import('./CardImagesModal'));
const SoundsModal = lazy(() => import('./SoundsModal'));
import TimeInput, { TimeInputModeToggle } from './TimeInput';
import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages } from '@toastmaster-timer/shared';
import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE } from '../utils/zoomSdk';
import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE } from '../utils/zoomSdk';
import { useClub } from '../hooks/useClub';
import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds';
import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared';
Expand Down Expand Up @@ -630,16 +630,23 @@ export default memo(function LiveTab() {

startTimer();

// Track timer started event
(window.requestIdleCallback || setTimeout)(() => trackEvent('timer_started', {
speaker_name: speakerName || 'Unnamed',
role: selectedRole,
timing_rules: {
green: rules.green,
yellow: rules.yellow,
red: rules.red
}
}));
// Track timer started event. audience_size is the meeting's head count
// read at START (people join after the app opens); it is left out when
// Zoom withholds the list (non-host), there's no SDK, or the read fails.
(window.requestIdleCallback || setTimeout)(() => {
getZoomParticipants()
.catch(() => ({ participants: [], restricted: true }))
.then(({ participants, restricted }) => trackEvent('timer_started', {
speaker_name: speakerName || 'Unnamed',
role: selectedRole,
timing_rules: {
green: rules.green,
yellow: rules.yellow,
red: rules.red
},
...(!restricted && participants.length > 0 && { audience_size: participants.length }),
}));
});
};

const handleContinue = () => {
Expand Down
124 changes: 123 additions & 1 deletion apps/zoom-app/src/components/LiveTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { ToastProvider } from '../context/ToastContext';
import { TimerProvider } from '../context/TimerContext';
import LiveTab from './LiveTab';
import { BREAK_ROLE, DEFAULT_ROLE_RULES, deriveBreakRules } from '@toastmaster-timer/shared';
import { applyOverlay, clearVideoPipelines, isOverlayActive } from '../utils/zoomSdk';
import { applyOverlay, clearVideoPipelines, isOverlayActive, getZoomParticipants } from '../utils/zoomSdk';
import { trackEvent } from '../utils/posthog';

// Stubbed rather than imported: the real module pulls in @zoom/appssdk, which
// hangs vitest under jsdom. Faithful to the real return shapes, since the tab
Expand Down Expand Up @@ -230,3 +231,124 @@ describe('the starting role when Standard Speech is removed', () => {
expect(applyOverlay).toHaveBeenCalled();
});
});

// Reach: how many people a START served, not how many times it was pressed.
// Only a count leaves the device, and only when Zoom gave us the whole list.
describe('timer_started and the meeting audience (#107)', () => {
const NO_PARTICIPANTS = { participants: [], role: 'host', restricted: false };
const people = (n) => Array.from({ length: n }, (_, i) => ({ id: `p${i}`, name: `Person ${i}` }));
const startButton = () => screen.getByRole('button', { name: /start/i });
const STANDARD = DEFAULT_ROLE_RULES['Standard Speech'];

afterEach(() => {
// Back to the module mock's default so other suites see an empty list.
getZoomParticipants.mockReset();
getZoomParticipants.mockResolvedValue(NO_PARTICIPANTS);
});

/** Let the idle callback and any participant read behind it run to completion. */
async function flushIdleWork() {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
await act(async () => {
for (let i = 0; i < 10; i += 1) await Promise.resolve();
});
}

/**
* Open the app with `atOpen` in the meeting, let the mount-time reads
* (the speaker suggestions) finish, then forget them: every assertion on
* getZoomParticipants below is about the read START makes.
*/
async function openApp(atOpen = NO_PARTICIPANTS) {
getZoomParticipants.mockResolvedValue(atOpen);
renderLive();
await flushIdleWork();
getZoomParticipants.mockClear();
}

const timerStartedCalls = () => trackEvent.mock.calls.filter(([name]) => name === 'timer_started');

/** The one timer_started event's properties; fails if it was not sent exactly once. */
function timerStartedProps() {
const calls = timerStartedCalls();
expect(calls).toHaveLength(1);
return calls[0][1];
}

it('AC-1: carries audience_size for a host with the full list, alongside the usual properties', async () => {
const meeting = { participants: people(3), role: 'host', restricted: false };
await openApp(meeting);
getZoomParticipants.mockResolvedValue(meeting);
pickFromAgenda('Alice');

fireEvent.click(startButton());
await flushIdleWork();

expect(timerStartedProps()).toEqual({
speaker_name: 'Alice',
role: 'Standard Speech',
timing_rules: { green: STANDARD.green, yellow: STANDARD.yellow, red: STANDARD.red },
audience_size: 3,
});
});

it('AC-2: counts the people in the meeting at START, not when the app opened', async () => {
await openApp({ participants: people(1), role: 'host', restricted: false });
// Three more people join after the app opened.
getZoomParticipants.mockResolvedValue({ participants: people(4), role: 'host', restricted: false });

fireEvent.click(startButton());
await flushIdleWork();

expect(getZoomParticipants).toHaveBeenCalled();
expect(timerStartedProps()).toMatchObject({ audience_size: 4 });
});

it('AC-3: leaves audience_size out when the organizer is neither host nor co-host', async () => {
const meeting = { participants: people(1), role: 'attendee', restricted: true };
await openApp(meeting);
getZoomParticipants.mockResolvedValue(meeting);

fireEvent.click(startButton());
await flushIdleWork();

// The read at START was made, so leaving the count out is the result of
// `restricted`, not of never asking.
expect(getZoomParticipants).toHaveBeenCalled();
const props = timerStartedProps();
expect(props).not.toHaveProperty('audience_size');
expect(props).toMatchObject({ role: 'Standard Speech' });
});

it('AC-4: leaves audience_size out when there is no Zoom SDK (empty participant list)', async () => {
const noSdk = { participants: [], role: '', restricted: false };
await openApp(noSdk);
getZoomParticipants.mockResolvedValue(noSdk);

fireEvent.click(startButton());
await flushIdleWork();

expect(getZoomParticipants).toHaveBeenCalled();
const props = timerStartedProps();
expect(props).not.toHaveProperty('audience_size');
expect(props).toMatchObject({ role: 'Standard Speech' });
});

it('AC-5: starts at once and still sends timer_started, without audience_size, when the participant read fails', async () => {
await openApp({ participants: people(2), role: 'host', restricted: false });
getZoomParticipants.mockRejectedValue(new Error('SDK init failed'));

fireEvent.click(startButton());
// START never waits on Zoom: the timer is running before any idle work.
expect(screen.getByRole('button', { name: 'FINISH' })).toBeInTheDocument();

await flushIdleWork();

expect(getZoomParticipants).toHaveBeenCalled();
const props = timerStartedProps();
expect(props).not.toHaveProperty('audience_size');
expect(props).toMatchObject({ role: 'Standard Speech' });
});
});
2 changes: 1 addition & 1 deletion e2e/zoom-client/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Read this first. Update it in the same PR as any spec change.

| Spec | Status | Verified | Covers |
|---|---|---|---|
| [timer-core](timer-core.md) | draft | never | start/colours/finish, resume after close |
| [timer-core](timer-core.md) | current | 2e965b6, 2026-11-11 | start/colours/finish, resume after close; `audience_size` on `timer_started` for hosts, omitted for non-hosts (#107) |
| [agenda-import](agenda-import.md) | draft | never | Simple Format import assigns custom roles from the bracket text; imported role's thresholds on real video (#77) |
| [zoom-deeplink](zoom-deeplink.md) | draft | never | "Open in Zoom" deeplink resolves and launches this build's app (dev vs production client id) |
| auth-identity | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 1, 1b |
Expand Down
Loading
Loading