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
2 changes: 2 additions & 0 deletions apps/website/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../lib/site-metadata';
import { getFormPolicy } from '../lib/growth/form-policy';
import { WebsiteSignals } from '../components/shared/WebsiteSignals';
import { EngagedTimeSignal } from '../components/shared/EngagedTimeSignal';
import { websiteContentCatalog } from '../lib/growth/website-content';

const display = Archivo_Black({
Expand Down Expand Up @@ -97,6 +98,7 @@ export default function RootLayout({
*/}
<JsonLd data={rootJsonLd()} />
<WebsiteSignals catalog={websiteContentCatalog()} />
<EngagedTimeSignal />
<Nav />
<div id="site-content">
<main>
Expand Down
137 changes: 137 additions & 0 deletions apps/website/src/components/shared/EngagedTimeSignal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { render, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EngagedTimeSignal } from './EngagedTimeSignal';

vi.mock('../../lib/analytics/client', () => ({
trackEngagedTime: vi.fn(),
}));

let pathname = '/';
vi.mock('next/navigation', () => ({
usePathname: () => pathname,
}));

import { trackEngagedTime } from '../../lib/analytics/client';

const tracked = vi.mocked(trackEngagedTime);

/**
* `engaged-time.spec.ts` covers the accounting. These cover the wiring, which
* is where this can silently do nothing: an effect that never runs, an
* interval that is never installed, or a listener that outlives the component.
* The unit tests would stay green through all three.
*/
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => state,
});
document.dispatchEvent(new Event('visibilitychange'));
}

describe('EngagedTimeSignal', () => {
beforeEach(() => {
vi.useFakeTimers();
tracked.mockClear();
pathname = '/';
setVisibility('visible');
});

afterEach(() => {
cleanup();
vi.useRealTimers();
});

it('reports ten engaged seconds after ten visible seconds', () => {
render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(tracked).toHaveBeenCalledWith(10);
});

it('reports nothing before the first threshold', () => {
render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(9_000);
});
expect(tracked).not.toHaveBeenCalled();
});

it('reports thirty seconds as a second event, not a replacement', () => {
render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(tracked).toHaveBeenNthCalledWith(1, 10);
expect(tracked).toHaveBeenNthCalledWith(2, 30);
});

it('does not accrue engagement while the tab is hidden', () => {
render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(3_000);
setVisibility('hidden');
vi.advanceTimersByTime(120_000);
});
expect(tracked).not.toHaveBeenCalled();
});

it('reports nothing for a tab that was never visible at mount', () => {
// THE honesty property, asserted where it is load-bearing. The production
// implementation of "a never-seen tab reports nothing" is the single
// `startVisible: document.visibilityState === 'visible'` argument; a
// regression to `true` would let a background tab's throttled interval
// fire both thresholds for a page nobody ever looked at.
setVisibility('hidden');
render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(120_000);
});
expect(tracked).not.toHaveBeenCalled();
});

it('ignores visibility changes after unmount', () => {
// A leaked listener would let one tab-away emit engaged_time for every
// page visited earlier in the session, each stamped with the CURRENT
// source_page, breaking the two-events-per-pageview bound.
const { unmount } = render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(30_000);
});
tracked.mockClear();
unmount();
act(() => {
setVisibility('hidden');
setVisibility('visible');
vi.advanceTimersByTime(60_000);
});
expect(tracked).not.toHaveBeenCalled();
});

it('starts a fresh budget on each pathname, not once per document', () => {
const { rerender } = render(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(tracked).toHaveBeenCalledTimes(2);
tracked.mockClear();
pathname = '/pricing';
rerender(<EngagedTimeSignal />);
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(tracked).toHaveBeenCalledExactlyOnceWith(10);
});

it('stops reporting once unmounted', () => {
// A leaked interval would keep attributing engagement to a page the
// visitor has already navigated away from.
const { unmount } = render(<EngagedTimeSignal />);
unmount();
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(tracked).not.toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions apps/website/src/components/shared/EngagedTimeSignal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { createEngagedTimeTracker } from '../../lib/analytics/engaged-time';
import { trackEngagedTime } from '../../lib/analytics/client';

/**
* How often to re-evaluate. The tracker derives elapsed time from a clock
* rather than counting ticks, so a browser throttling this interval in a
* background tab delays the event but never miscounts it.
*/
const TICK_MS = 1_000;

/**
* Emits `marketing:engaged_time` once the visitor has genuinely been looking
* at the page for 10s, and again at 30s.
*
* Without this the site has no passive engagement signal on most viewports:
* `marketing:stage_progress` is gated to at least 1024x720 and fired on 0.0%
* of mobile sessions in the 30 days to 2026-09-18. A visitor who reads and
* clicks nothing emitted one `$pageview` and nothing else, which PostHog
* scores as a zero-second session and therefore a bounce.
*
* Re-created per pathname so engaged time is measured per page rather than
* per document, matching `capture_pageview: 'history_change'`.
*/
export function EngagedTimeSignal() {
const pathname = usePathname();

useEffect(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') return;

const tracker = createEngagedTimeTracker({
now: () => Date.now(),
startVisible: document.visibilityState === 'visible',
onThreshold: (thresholdMs) => trackEngagedTime(Math.round(thresholdMs / 1000)),
});

const onVisibilityChange = () => {
tracker.setVisible(document.visibilityState === 'visible');
// Re-evaluate immediately: returning to a tab that already passed a
// threshold should not wait out another interval.
tracker.tick();
};

document.addEventListener('visibilitychange', onVisibilityChange);
// Stop waking up once both thresholds have fired. Without this the
// interval runs at 1Hz for the life of every page on the site, doing
// nothing.
const intervalId = window.setInterval(() => {
tracker.tick();
if (tracker.isComplete()) window.clearInterval(intervalId);
}, TICK_MS);

return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
window.clearInterval(intervalId);
};
}, [pathname]);

return null;
}
8 changes: 8 additions & 0 deletions apps/website/src/lib/analytics/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ export function trackStageProgress(
...(beat ? { beat } : {}),
});
}

/**
* Passive engagement. `engaged_seconds` is visible time only, so it can be
* read as real attention rather than a tab left open.
*/
export function trackEngagedTime(engaged_seconds: number) {
track(analyticsEvents.marketingEngagedTime, { engaged_seconds });
}
150 changes: 150 additions & 0 deletions apps/website/src/lib/analytics/engaged-time.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import {
createEngagedTimeTracker,
ENGAGED_TIME_THRESHOLDS_MS,
} from './engaged-time';

/**
* Why this exists.
*
* PostHog derives `session_duration` from `max(timestamp) - min(timestamp)`
* over a session's events, and `$pageleave` only fires on `pagehide` — never
* on `visibilitychange`. A visitor who lands, reads, clicks nothing and leaves
* the tab open therefore emits exactly one event, records a duration of zero,
* and is classified as a bounce. Measured 2026-09-18: that is 13.6% of desktop
* and 17.8% of mobile homepage sessions.
*
* This tracker exists to make the recorded duration reflect the real one. That
* only holds if it counts *visible* time and nothing else — time in a hidden
* background tab is not engagement, and counting it would inflate the metric
* rather than correct it. The hidden-time tests below are the ones that keep
* this honest, so treat them as load-bearing rather than incidental.
*/

function setup(startVisible = true) {
let clock = 1_000;
const onThreshold = vi.fn();
const tracker = createEngagedTimeTracker({
onThreshold,
now: () => clock,
startVisible,
});
return {
onThreshold,
tracker,
advance(ms: number) {
clock += ms;
tracker.tick();
},
advanceWithoutTick(ms: number) {
clock += ms;
},
};
}

describe('engaged time thresholds', () => {
it('exports 10s and 30s, the first matching PostHog bounce cutoff', () => {
// 10s is not arbitrary: PostHog's bounce test is `session_duration >= 10s`.
// An event at a genuine 10 visible seconds makes the recorded duration
// match reality for a reader who never clicks.
expect(ENGAGED_TIME_THRESHOLDS_MS).toEqual([10_000, 30_000]);
});

it('fires the first threshold only once ten visible seconds have passed', () => {
const { advance, onThreshold } = setup();
advance(9_999);
expect(onThreshold).not.toHaveBeenCalled();
advance(1);
expect(onThreshold).toHaveBeenCalledExactlyOnceWith(10_000);
});

it('fires each threshold exactly once, however many times it is ticked', () => {
const { advance, onThreshold } = setup();
advance(10_000);
advance(1_000);
advance(1_000);
expect(onThreshold).toHaveBeenCalledExactlyOnceWith(10_000);
});

it('fires the second threshold at thirty visible seconds', () => {
const { advance, onThreshold } = setup();
advance(30_000);
expect(onThreshold).toHaveBeenNthCalledWith(1, 10_000);
expect(onThreshold).toHaveBeenNthCalledWith(2, 30_000);
expect(onThreshold).toHaveBeenCalledTimes(2);
});

it('does not count time while the page is hidden', () => {
// The honesty test. A backgrounded tab accrues wall-clock time but no
// engagement; counting it would inflate session duration rather than
// correct it.
const { advance, tracker, onThreshold } = setup();
advance(5_000);
tracker.setVisible(false);
advance(60_000);
expect(onThreshold).not.toHaveBeenCalled();
expect(tracker.visibleMs()).toBe(5_000);
});

it('resumes accumulating when the page becomes visible again', () => {
const { advance, tracker, onThreshold } = setup();
advance(6_000);
tracker.setVisible(false);
advance(60_000);
tracker.setVisible(true);
advance(4_000);
expect(onThreshold).toHaveBeenCalledExactlyOnceWith(10_000);
expect(tracker.visibleMs()).toBe(10_000);
});

it('keeps time from earlier visible runs across repeated hide/show cycles', () => {
// One cycle cannot distinguish `accumulatedMs +=` from `accumulatedMs =`,
// because the accumulator is still zero. Two can.
const { advance, tracker, onThreshold } = setup();
advance(4_000);
tracker.setVisible(false);
advance(60_000);
tracker.setVisible(true);
advance(4_000);
tracker.setVisible(false);
advance(60_000);
tracker.setVisible(true);
advance(2_000);
expect(tracker.visibleMs()).toBe(10_000);
expect(onThreshold).toHaveBeenCalledExactlyOnceWith(10_000);
});

it('reports completion only once every threshold has fired', () => {
const { advance, tracker } = setup();
advance(10_000);
expect(tracker.isComplete()).toBe(false);
advance(20_000);
expect(tracker.isComplete()).toBe(true);
});

it('never fires for a page that starts hidden and is never seen', () => {
// A prerendered or background-opened tab must not report engagement.
const { advance, onThreshold, tracker } = setup(false);
advance(120_000);
expect(onThreshold).not.toHaveBeenCalled();
expect(tracker.visibleMs()).toBe(0);
});

it('counts time that elapsed between ticks, not tick count', () => {
// The production caller ticks on an interval; a throttled background
// timer must not under-report time the page was genuinely visible.
const { advanceWithoutTick, tracker, onThreshold } = setup();
advanceWithoutTick(30_000);
tracker.tick();
expect(onThreshold).toHaveBeenCalledTimes(2);
});

it('does not double count when told it is visible twice', () => {
const { advance, tracker, onThreshold } = setup();
advance(5_000);
tracker.setVisible(true);
advance(4_999);
expect(onThreshold).not.toHaveBeenCalled();
expect(tracker.visibleMs()).toBe(9_999);
});
});
Loading
Loading