diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54566fe..337b422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: --health-timeout 5s --health-retries 10 env: - DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/pagent_e2e?sslmode=disable + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/pagent_e2e?sslmode=verify-full PAGENT_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable steps: - uses: actions/checkout@v4 @@ -52,6 +52,31 @@ jobs: - name: Audit dependencies run: npm audit --audit-level=high + - name: Configure trusted PostgreSQL TLS for production-mode tests + env: + PAGENT_POSTGRES_CONTAINER: ${{ job.services.postgres.id }} + PGPASSWORD: postgres + PGSSLMODE: disable + run: | + pagent_tls_dir=$(mktemp -d) + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$pagent_tls_dir/server.key" -out "$pagent_tls_dir/server.crt" \ + -days 1 -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' + docker cp "$pagent_tls_dir/server.key" "$PAGENT_POSTGRES_CONTAINER:/tmp/pagent-test.key" + docker cp "$pagent_tls_dir/server.crt" "$PAGENT_POSTGRES_CONTAINER:/tmp/pagent-test.crt" + docker exec --user root "$PAGENT_POSTGRES_CONTAINER" chown postgres:postgres /tmp/pagent-test.key /tmp/pagent-test.crt + docker exec --user root "$PAGENT_POSTGRES_CONTAINER" chmod 600 /tmp/pagent-test.key + psql --host=127.0.0.1 --port=5432 --username=postgres --dbname=postgres \ + -c "ALTER SYSTEM SET ssl_cert_file = '/tmp/pagent-test.crt'" \ + -c "ALTER SYSTEM SET ssl_key_file = '/tmp/pagent-test.key'" \ + -c "ALTER SYSTEM SET ssl = 'on'" -c 'SELECT pg_reload_conf()' + echo "NODE_EXTRA_CA_CERTS=$pagent_tls_dir/server.crt" >> "$GITHUB_ENV" + echo "PGSSLROOTCERT=$pagent_tls_dir/server.crt" >> "$GITHUB_ENV" + + - name: Verify production test database TLS + run: | + test "$(psql "$DATABASE_URL" -XAtc 'select ssl from pg_stat_ssl where pid = pg_backend_pid()')" = t + - name: Prepare deck integration databases env: PGPASSWORD: postgres @@ -61,6 +86,8 @@ jobs: pagent_deck_schema_d3a0_test \ pagent_deck_routes_permissions_d3a0_test \ pagent_deck_engagement_d3a0_test \ + pagent_deck_engagement_concurrent_d3a0_test \ + pagent_engagement_readiness_20260922_test \ pagent_deck_permissions_d3a0_test \ pagent_deck_sharing_d3a0_test \ pagent_deck_routes_sharing_d3a0_test \ diff --git a/apps/api/.env.example b/apps/api/.env.example index 80b58ce..677795e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -63,7 +63,8 @@ OTEL_SERVICE_NAME=pagent-api LOG_LEVEL=info # Per-client-IP, in-memory write limit for temporary page creation and public -# viewer access/visit starts. Engagement event delivery receives 20x this cap. +# viewer access/visit starts. Engagement delivery allows 4x this IP cap, plus +# 30 requests per viewer session per window and at most 10 events per request. RATE_LIMIT_MAX=30 RATE_LIMIT_WINDOW_MS=60000 @@ -74,15 +75,16 @@ RATE_LIMIT_WINDOW_MS=60000 TRUSTED_PROXY_MODE= # --- Auth (OAuth 2.1 + sessions) --------------------------------------------- -# Auth enforcement is behind the REQUIRE_AUTH toggle. While REQUIRE_AUTH=false, -# protected page operations accept anonymous requests. OAuth routes remain -# mounted; flows needing unconfigured providers (such as SMTP) fail closed. +# Durable presentation writes and analytics reads always require authentication. +# REQUIRE_AUTH=false permits anonymous temporary page writes and response reads +# during rollout grace mode. Invalid credentials still fail closed. OAuth routes +# remain mounted; flows needing unconfigured providers (such as SMTP) fail closed. # When REQUIRE_AUTH=true, the following are required at boot or the server # exits with a clear error: JWT_SIGNING_KEY, JWT_PUBLIC_KEY, # AUTH_STATE_SECRET, SMTP_HOST, SMTP_USER, SMTP_PASS. # See docs/superpowers/specs/2026-05-17-auth-design.md. -# Master switch. Set to true to require auth on mutation endpoints (e.g. POST /new). +# Set to true to also require auth for temporary pages (e.g. POST /new). # Leave false during the grace-period rollout. REQUIRE_AUTH=false diff --git a/apps/api/decks/analytics-aggregate.test.ts b/apps/api/decks/analytics-aggregate.test.ts index 6fedc46..39268b8 100644 --- a/apps/api/decks/analytics-aggregate.test.ts +++ b/apps/api/decks/analytics-aggregate.test.ts @@ -115,7 +115,15 @@ describe('deck analytics aggregation', () => { }; // When - const analytics = aggregateDeckAnalytics(analyticsInput); + const eventRows = analyticsInput.engagementRows.map((row) => ({ + visitId: row.visitId, + slideId: row.id, + eventAt: firstStartedAt, + sequence: row.firstSequence, + activeDurationMs: row.activeDurationMs, + qualified: row.qualified, + })); + const analytics = aggregateDeckAnalytics({ ...analyticsInput, eventRows }); // Then expect(analytics).toEqual({ @@ -202,6 +210,7 @@ describe('deck analytics aggregation', () => { completion: 1, furthestSlide: 2, lastSlide: 1, + sequenceComplete: true, slideSequence: [ { slideId: 'slide-2', @@ -235,6 +244,7 @@ describe('deck analytics aggregation', () => { completion: 0.5, furthestSlide: 1, lastSlide: 1, + sequenceComplete: true, slideSequence: [ { slideId: 'slide-1', diff --git a/apps/api/decks/analytics-aggregate.ts b/apps/api/decks/analytics-aggregate.ts index f47f234..7e82db1 100644 --- a/apps/api/decks/analytics-aggregate.ts +++ b/apps/api/decks/analytics-aggregate.ts @@ -1,6 +1,7 @@ import type { AnalyticsAggregationInput, AnalyticsEngagementRow, + AnalyticsEventRow, AnalyticsVisitRow, DeckAnalytics, MutableVisitor, @@ -8,6 +9,7 @@ import type { SlideRollup, VisitDetail, } from './analytics-types.ts'; +import { qualifyingSlideSequence } from './analytics-sequence.ts'; function viewerKey(visit: AnalyticsVisitRow): string { if (visit.viewerUserId !== null) return `user:${visit.viewerUserId}`; @@ -17,7 +19,14 @@ function viewerKey(visit: AnalyticsVisitRow): string { } export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAnalytics { - const { owner, visitRows, slideRows, engagementRows } = input; + const { owner, visitRows, slideRows, engagementRows, eventRows } = input; + const slidesById = new Map(slideRows.map((slide) => [slide.id, slide])); + const eventsByVisit = new Map(); + for (const event of eventRows) { + const current = eventsByVisit.get(event.visitId) ?? []; + current.push(event); + eventsByVisit.set(event.visitId, current); + } const visitRowsById = new Map(); for (const visit of visitRows) { if (!visitRowsById.has(visit.id)) visitRowsById.set(visit.id, visit); @@ -31,7 +40,11 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn const visits: VisitDetail[] = visitRows.map((visit) => { const engagements = engagementsByVisit.get(visit.id) ?? []; const qualified = engagements.filter((engagement) => engagement.qualified); - const ordered = [...qualified].sort((left, right) => left.firstSequence - right.firstSequence); + const events = eventsByVisit.get(visit.id) ?? []; + const sequenceComplete = + events.every((event) => event.qualified !== null) && + (events.length > 0 || engagements.length === 0); + const slideSequence = sequenceComplete ? qualifyingSlideSequence(events, slidesById) : []; const distinct = new Set(qualified.map((engagement) => engagement.id)); const totalActiveTimeMs = engagements.reduce( (total, engagement) => total + engagement.activeDurationMs, @@ -55,14 +68,9 @@ export function aggregateDeckAnalytics(input: AnalyticsAggregationInput): DeckAn qualified.length === 0 ? null : Math.max(...qualified.map((engagement) => engagement.ordinal)), - lastSlide: ordered.at(-1)?.ordinal ?? null, - slideSequence: ordered.map((engagement) => ({ - slideId: engagement.id, - ordinal: engagement.ordinal, - title: engagement.title, - activeDurationMs: engagement.activeDurationMs, - viewCount: engagement.viewCount, - })), + lastSlide: slideSequence.at(-1)?.ordinal ?? null, + sequenceComplete, + slideSequence, }; }); const visitDetailsById = new Map(); diff --git a/apps/api/decks/analytics-appearances.test.ts b/apps/api/decks/analytics-appearances.test.ts new file mode 100644 index 0000000..90d7cdf --- /dev/null +++ b/apps/api/decks/analytics-appearances.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { aggregateDeckAnalytics } from './analytics-aggregate.ts'; + +const slides = [1, 2, 3].map((ordinal) => ({ + id: `slide-${ordinal}`, + stableSlideId: `stable-${ordinal}`, + ordinal, + title: `Slide ${ordinal}`, + revisionNumber: 1, +})); +const input = { + owner: { id: 'owner', email: 'owner@example.test' }, + visitRows: [ + { + id: 'visit', + viewerSessionId: 'session', + viewerUserId: null, + viewerEmail: null, + identityConfidence: 'anonymous' as const, + startedAt: new Date('2026-09-22T12:00:00Z'), + lastActivityAt: new Date('2026-09-22T12:00:10Z'), + revisionNumber: 1, + linkId: 'link', + linkName: 'Link', + senderId: 'owner', + senderEmail: 'owner@example.test', + totalSlides: 3, + }, + ], + slideRows: slides, + engagementRows: slides.slice(0, 2).map((slide) => ({ + ...slide, + visitId: 'visit', + activeDurationMs: slide.ordinal === 1 ? 6_000 : 4_000, + viewCount: slide.ordinal === 1 ? 2 : 1, + qualified: true, + firstSequence: slide.ordinal, + lastSequence: slide.ordinal === 1 ? 3 : 2, + })), +}; + +function event(slide: number, sequence: number, duration: number) { + return { + visitId: 'visit', + slideId: `slide-${slide}`, + sequence, + eventAt: new Date(Date.UTC(2026, 8, 22, 12, 0, sequence)), + activeDurationMs: duration, + qualified: true, + }; +} + +describe('qualifying analytics appearances', () => { + it('preserves a return to an earlier slide and counts the final exit there', () => { + // Given + const fixture = { + ...input, + eventRows: [event(1, 1, 3_000), event(2, 2, 4_000), event(1, 3, 3_000)], + }; + // When + const analytics = aggregateDeckAnalytics(fixture); + // Then + expect(analytics.visits[0]?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]); + expect(analytics.visits[0]).toMatchObject({ lastSlide: 1, viewedSlides: 2, completion: 2 / 3 }); + expect(analytics.slides.map((slide) => slide.exits)).toEqual([1, 0, 0]); + }); + + it('counts accepted intervals once across repeated appearances and heartbeats', () => { + // Given + const fixture = { + ...input, + eventRows: [ + event(1, 1, 1_000), + { ...event(1, 2, 2_000), qualified: false }, + event(2, 3, 4_000), + event(1, 4, 3_000), + ], + }; + // When + const sequence = aggregateDeckAnalytics(fixture).visits[0]?.slideSequence; + // Then + expect(sequence?.map((slide) => slide.activeDurationMs)).toEqual([3_000, 4_000, 3_000]); + expect(sequence?.reduce((total, slide) => total + slide.activeDurationMs, 0)).toBe(10_000); + expect(sequence?.map((slide) => slide.viewCount)).toEqual([1, 1, 1]); + }); + + it('does not turn an unqualified later appearance into the last viewed slide', () => { + // Given + const fixture = { + ...input, + eventRows: [ + event(1, 1, 3_000), + event(2, 2, 4_000), + { ...event(1, 3, 500), qualified: false }, + ], + }; + // When + const visit = aggregateDeckAnalytics(fixture).visits[0]; + // Then + expect(visit?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2]); + expect(visit?.lastSlide).toBe(2); + }); + + it('orders appearances by event time when batches arrive out of order or sequence resets', () => { + // Given + const fixture = { + ...input, + eventRows: [{ ...event(1, 3, 3_000), sequence: 0 }, event(2, 2, 4_000), event(1, 1, 3_000)], + }; + // When + const visit = aggregateDeckAnalytics(fixture).visits[0]; + // Then + expect(visit?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]); + }); + + it.each([false, true])( + 'preserves historical totals but reports unknown order for legacy or mixed events (mixed: %s)', + (mixed) => { + // Given + const fixture = { + ...input, + eventRows: [ + { ...event(1, 1, 3_000), qualified: null }, + { ...event(2, 2, 4_000), qualified: mixed ? true : null }, + ], + }; + // When + const analytics = aggregateDeckAnalytics(fixture); + const visit = analytics.visits[0]; + // Then + expect(visit).toMatchObject({ + sequenceComplete: false, + lastSlide: null, + slideSequence: [], + viewedSlides: 2, + completion: 2 / 3, + }); + expect(visit?.totalActiveTimeMs).toBe(10_000); + expect(analytics.slides.map((slide) => slide.totalActiveTimeMs)).toEqual([6_000, 4_000, 0]); + expect(analytics.slides.map((slide) => slide.exits)).toEqual([0, 0, 0]); + }, + ); +}); diff --git a/apps/api/decks/analytics-sequence.ts b/apps/api/decks/analytics-sequence.ts new file mode 100644 index 0000000..6e9f683 --- /dev/null +++ b/apps/api/decks/analytics-sequence.ts @@ -0,0 +1,39 @@ +import type { AnalyticsEventRow, AnalyticsSlideRow, VisitDetail } from './analytics-types.ts'; + +export function qualifyingSlideSequence( + events: readonly AnalyticsEventRow[], + slides: ReadonlyMap, +): VisitDetail['slideSequence'] { + const appearances: { + slideId: string; + ordinal: number; + title: string | null; + activeDurationMs: number; + viewCount: number; + }[] = []; + let current: (typeof appearances)[number] | undefined; + const ordered = [...events].sort( + (left, right) => + left.eventAt.getTime() - right.eventAt.getTime() || left.sequence - right.sequence, + ); + for (const event of ordered) { + const slide = event.slideId === null ? undefined : slides.get(event.slideId); + if (slide === undefined) { + current = undefined; + continue; + } + if (current?.slideId !== slide.id || (event.qualified && current.viewCount > 0)) { + current = { + slideId: slide.id, + ordinal: slide.ordinal, + title: slide.title, + activeDurationMs: 0, + viewCount: 0, + }; + appearances.push(current); + } + current.activeDurationMs += event.activeDurationMs; + if (event.qualified) current.viewCount += 1; + } + return appearances.filter((appearance) => appearance.viewCount > 0); +} diff --git a/apps/api/decks/analytics-types.ts b/apps/api/decks/analytics-types.ts index 3a5acf5..2d16b94 100644 --- a/apps/api/decks/analytics-types.ts +++ b/apps/api/decks/analytics-types.ts @@ -33,6 +33,15 @@ export type AnalyticsEngagementRow = AnalyticsSlideRow & { readonly lastSequence: number; }; +export type AnalyticsEventRow = { + readonly visitId: string; + readonly slideId: string | null; + readonly eventAt: Date; + readonly sequence: number; + readonly activeDurationMs: number; + readonly qualified: boolean | null; +}; + export type VisitDetail = { readonly id: string; readonly viewer: string; @@ -49,6 +58,7 @@ export type VisitDetail = { readonly completion: number; readonly furthestSlide: number | null; readonly lastSlide: number | null; + readonly sequenceComplete: boolean; readonly slideSequence: readonly { readonly slideId: string; readonly ordinal: number; @@ -119,4 +129,5 @@ export type AnalyticsAggregationInput = { readonly visitRows: readonly AnalyticsVisitRow[]; readonly slideRows: readonly AnalyticsSlideRow[]; readonly engagementRows: readonly AnalyticsEngagementRow[]; + readonly eventRows: readonly AnalyticsEventRow[]; }; diff --git a/apps/api/decks/analytics.ts b/apps/api/decks/analytics.ts index a66543a..31e8b06 100644 --- a/apps/api/decks/analytics.ts +++ b/apps/api/decks/analytics.ts @@ -1,5 +1,5 @@ const HEARTBEAT_CEILING_MS = 10_000; -const FUTURE_SKEW_MS = 30_000; +export const FUTURE_SKEW_MS = 30_000; export type VisitSummary = { readonly completion: number; diff --git a/apps/api/decks/migrations/0003-add-engagement-event-qualification.ts b/apps/api/decks/migrations/0003-add-engagement-event-qualification.ts new file mode 100644 index 0000000..f78f2d8 --- /dev/null +++ b/apps/api/decks/migrations/0003-add-engagement-event-qualification.ts @@ -0,0 +1,10 @@ +import type { DeckMigration } from '../schema-migrations.ts'; + +export const engagementEventQualificationMigration = { + version: 3, + name: 'add_engagement_event_qualification', + async up(database) { + // Historical events omit visible duration, so their qualification cannot be backfilled. + await database`alter table engagement_events add column if not exists qualified boolean`; + }, +} satisfies DeckMigration; diff --git a/apps/api/decks/repository-analytics.ts b/apps/api/decks/repository-analytics.ts index a77c9a9..7721066 100644 --- a/apps/api/decks/repository-analytics.ts +++ b/apps/api/decks/repository-analytics.ts @@ -2,6 +2,7 @@ import * as db from '../db.ts'; import { aggregateDeckAnalytics } from './analytics-aggregate.ts'; import type { AnalyticsEngagementRow, + AnalyticsEventRow, AnalyticsSlideRow, AnalyticsVisitRow, DeckAnalytics, @@ -179,6 +180,7 @@ export async function getDeckAnalytics( visitRows, slideRows: slides, engagementRows: [], + eventRows: [], }); } const visitIds = visitRows.map((visit) => visit.id); @@ -218,10 +220,18 @@ export async function getDeckAnalytics( firstSequence: row.first_sequence, lastSequence: row.last_sequence, })); + const eventRows = await database` + select visit_id as "visitId", slide_id as "slideId", event_at as "eventAt", + sequence, accepted_duration_ms as "activeDurationMs", qualified + from engagement_events + where visit_id in ${database(visitIds)} + order by event_at, sequence, server_received_at, idempotency_key + `; return aggregateDeckAnalytics({ owner: { id: access.ownerId, email: access.ownerEmail }, visitRows, slideRows: slides, engagementRows: engagements, + eventRows, }); } diff --git a/apps/api/decks/repository-engagement-readiness.integration.test.ts b/apps/api/decks/repository-engagement-readiness.integration.test.ts new file mode 100644 index 0000000..a3a7807 --- /dev/null +++ b/apps/api/decks/repository-engagement-readiness.integration.test.ts @@ -0,0 +1,257 @@ +import { randomUUID } from 'node:crypto'; +import postgres from 'postgres'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import * as db from '../db.ts'; +import { getDeckAnalytics } from './repository-analytics.ts'; +import { publishDeck } from './repository-decks.ts'; +import { + EngagementForbiddenError, + ingestEngagement, + startVisit, + type EngagementEvent, +} from './repository-engagement.ts'; +import { createShareLink, grantViewerAccess } from './repository-sharing.ts'; +import { initDeckSchema } from './schema.ts'; +import { integrationDatabaseUrl } from './test-database.ts'; + +const databaseUrl = integrationDatabaseUrl('pagent_engagement_readiness_20260922_test'); +const integration = describe.runIf(databaseUrl !== undefined); + +integration('engagement readiness regressions', () => { + const setup = postgres(databaseUrl ?? '', { ssl: false, prepare: false }); + let ownerId = ''; + let deckId = ''; + let token = ''; + let visitId = ''; + let slides: string[] = []; + let base = new Date(); + + beforeAll(async () => { + await setup`drop schema public cascade`; + await setup`create schema public`; + await db.init(databaseUrl ?? ''); + await initDeckSchema(db.database()); + const owner = await db.upsertUser({ + email: 'readiness@pagent.test', + handle: 'readiness', + name: 'Readiness', + avatarUrl: null, + }); + ownerId = owner.id; + }); + + beforeEach(async () => { + const deck = await publishDeck( + { id: ownerId, email: 'readiness@pagent.test' }, + { + title: 'Readiness', + slides: [1, 2, 3].map((ordinal) => ({ + id: `slide-${ordinal}`, + html: `

${ordinal}

`, + })), + }, + ); + deckId = deck.deckId; + const rows = await db.database()< + { id: string }[] + >`select id from deck_slides where revision_id = ${deck.revisionId} order by ordinal`; + slides = rows.map((row) => row.id); + const link = await createShareLink(ownerId, deckId, { + name: 'Review', + access_mode: 'anyone', + allowed_emails: [], + allowed_domains: [], + }); + const access = await grantViewerAccess({ token: link.token }); + if (access.kind !== 'granted') throw new TypeError('fixture access was not granted'); + token = access.sessionToken; + const visit = await startVisit(token, { + visible: true, + interacted: true, + analyticsConsent: true, + deviceClass: 'desktop', + browserFamily: 'Chromium', + countryCode: null, + }); + if (visit.kind !== 'started') throw new TypeError('fixture visit was not started'); + visitId = visit.visitId; + base = new Date(Date.now() - 20_000); + await db.database()`update visits set started_at = ${base}, last_activity_at = ${base} where id = ${visitId}`; + }); + + afterAll(async () => { + await db.shutdown(); + await setup.end({ timeout: 5 }); + }); + + function event( + slide: number, + elapsed: number, + overrides: Partial = {}, + ): EngagementEvent { + return { + id: randomUUID(), + eventType: 'slide_view', + slideId: slides[slide - 1], + eventAt: new Date(base.getTime() + elapsed), + sequence: elapsed, + visibleRatio: 1, + visibleDurationMs: 1_000, + tabVisible: true, + recentlyActive: true, + ...overrides, + }; + } + + it('preserves qualifying repeated appearances from persisted events', async () => { + // Given + const events = [event(1, 3_000), event(2, 7_000), event(1, 10_000)]; + // When + await ingestEngagement(token, visitId, events); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + // Then + expect(analytics.visits[0]).toMatchObject({ lastSlide: 1, viewedSlides: 2, completion: 2 / 3 }); + expect(analytics.visits[0]?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]); + expect(analytics.visits[0]?.slideSequence.map((slide) => slide.activeDurationMs)).toEqual([ + 3_000, 4_000, 3_000, + ]); + expect(analytics.slides.map((slide) => slide.exits)).toEqual([1, 0, 0]); + }); + + it('preserves the earliest first-seen timestamp when an older event arrives later', async () => { + // Given + await ingestEngagement(token, visitId, [event(1, 10_000)]); + const older = event(1, 3_000); + // When + await ingestEngagement(token, visitId, [older, older]); + const rows = await db.database()< + { first_seen_at: Date; last_seen_at: Date; active_duration_ms: string }[] + >` + select first_seen_at, last_seen_at, active_duration_ms::text from slide_engagement where visit_id = ${visitId} + `; + // Then + expect(rows[0]).toEqual({ + first_seen_at: older.eventAt, + last_seen_at: new Date(base.getTime() + 10_000), + active_duration_ms: '10000', + }); + }); + + it.each(['stale', 'ended'] as const)( + 'rejects a %s visit without refreshing activity or persisting an event', + async (state) => { + // Given + const previousActivity = new Date(Date.now() - (state === 'stale' ? 31 * 60_000 : 20_000)); + await db.database()`update visits set last_activity_at = ${previousActivity}, ended_at = ${state === 'ended' ? previousActivity : null} where id = ${visitId}`; + // When + const result = ingestEngagement(token, visitId, [event(1, 15_000)]); + // Then + await expect(result).rejects.toBeInstanceOf(EngagementForbiddenError); + const rows = await db.database()<{ last_activity_at: Date; event_count: number }[]>` + select last_activity_at, (select count(*)::integer from engagement_events where visit_id = ${visitId}) as event_count from visits where id = ${visitId} + `; + expect(rows[0]).toEqual({ last_activity_at: previousActivity, event_count: 0 }); + }, + ); + + it('creates a different visit after an idle visit expires', async () => { + // Given + await db.database()`update visits set last_activity_at = now() - interval '31 minutes' where id = ${visitId}`; + // When + const next = await startVisit(token, { + visible: true, + interacted: true, + analyticsConsent: true, + deviceClass: 'desktop', + browserFamily: 'Chromium', + countryCode: null, + }); + // Then + expect(next.kind).toBe('started'); + if (next.kind === 'started') expect(next.visitId).not.toBe(visitId); + }); + + it('keeps a short final appearance unqualified even when that slide qualified earlier', async () => { + // Given + await ingestEngagement(token, visitId, [event(1, 3_000), event(2, 7_000)]); + // When + await ingestEngagement(token, visitId, [event(1, 7_500, { visibleDurationMs: 500 })]); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + // Then + expect(analytics.visits[0]?.lastSlide).toBe(2); + expect(analytics.visits[0]?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2]); + }); + + it('caps trusted time and leaves a repeated event idempotent', async () => { + // Given + const inflated = event(1, 20_000, { visibleDurationMs: 9_999_999 }); + // When + await ingestEngagement(token, visitId, [inflated, inflated]); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + // Then + expect(analytics.visits[0]?.totalActiveTimeMs).toBe(10_000); + }); + + it('rejects impossible future events without qualifying a slide or advancing visit activity', async () => { + // Given + const future = event(1, 80_000); + // When + await ingestEngagement(token, visitId, [future]); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + // Then + expect(analytics.visits[0]).toMatchObject({ + lastActivityAt: base, + totalActiveTimeMs: 0, + viewedSlides: 0, + lastSlide: null, + }); + }); + + it('credits only the interval after a zero-visibility resume boundary', async () => { + // Given + await db.database()`update visits set last_activity_at = now() - interval '5 minutes' where id = ${visitId}`; + const baseline = event(1, 19_000, { + eventType: 'heartbeat', + visibleRatio: 0, + visibleDurationMs: 0, + }); + // When + await ingestEngagement(token, visitId, [baseline, event(1, 20_000)]); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + // Then + expect(analytics.visits[0]?.totalActiveTimeMs).toBe(1_000); + }); + + it.each([false, true])( + 'preserves historical totals across migration without inventing old qualification (mixed: %s)', + async (mixed) => { + // Given + await ingestEngagement(token, visitId, [event(1, 3_000)]); + await db.database()`alter table engagement_events drop column qualified`; + await db.database()`delete from deck_schema_migrations where version = 3`; + // When + await initDeckSchema(db.database()); + if (mixed) await ingestEngagement(token, visitId, [event(2, 7_000)]); + const analytics = await getDeckAnalytics(ownerId, deckId, {}); + const events = await db.database()<{ qualified: boolean | null }[]>` + select qualified from engagement_events where visit_id = ${visitId} order by event_at + `; + // Then + expect(events.map((entry) => entry.qualified)).toEqual(mixed ? [null, true] : [null]); + expect(analytics.visits[0]).toMatchObject({ + sequenceComplete: false, + slideSequence: [], + lastSlide: null, + totalActiveTimeMs: mixed ? 7_000 : 3_000, + viewedSlides: mixed ? 2 : 1, + completion: (mixed ? 2 : 1) / 3, + }); + expect(analytics.slides.map((slide) => slide.totalActiveTimeMs)).toEqual([ + 3_000, + mixed ? 4_000 : 0, + 0, + ]); + expect(analytics.slides.map((slide) => slide.exits)).toEqual([0, 0, 0]); + }, + ); +}); diff --git a/apps/api/decks/repository-engagement.ts b/apps/api/decks/repository-engagement.ts index 4e85c64..e62814a 100644 --- a/apps/api/decks/repository-engagement.ts +++ b/apps/api/decks/repository-engagement.ts @@ -1,5 +1,5 @@ import * as db from '../db.ts'; -import { deriveAcceptedInterval } from './analytics.ts'; +import { deriveAcceptedInterval, FUTURE_SKEW_MS } from './analytics.ts'; import type { IdentityConfidence } from './domain.ts'; import { DeckDataInvariantError } from './errors.ts'; import { hashOpaqueToken } from './tokens.ts'; @@ -150,6 +150,7 @@ export async function ingestEngagement( join share_links sl on sl.id = v.share_link_id join decks d on d.id = sl.deck_id where v.id = ${visitId} and vs.token_hash = ${hashOpaqueToken(sessionToken)} + and v.ended_at is null and v.last_activity_at > now() - interval '30 minutes' and vs.revoked_at is null and vs.expires_at > now() and sl.revoked_at is null and (sl.expires_at is null or sl.expires_at > now()) and d.deleted_at is null @@ -164,6 +165,7 @@ export async function ingestEngagement( let lastActivity = visit.last_activity_at; const serverReceivedAt = new Date(); for (const event of events) { + if (event.eventAt.getTime() > serverReceivedAt.getTime() + FUTURE_SKEW_MS) continue; const slideId = event.slideId !== undefined && slideIds.has(event.slideId) ? event.slideId : null; const acceptedDurationMs = deriveAcceptedInterval({ @@ -174,25 +176,25 @@ export async function ingestEngagement( tabVisible: event.tabVisible, recentlyActive: event.recentlyActive, }); + const qualifies = + event.eventType === 'slide_view' && + slideId !== null && + event.visibleRatio >= 0.5 && + event.visibleDurationMs >= 1_000 && + event.tabVisible && + event.recentlyActive; const inserted = await tx<{ idempotency_key: string }[]>` insert into engagement_events ( idempotency_key, visit_id, event_type, slide_id, event_at, sequence, - visible_ratio, tab_visible, recently_active, accepted_duration_ms + visible_ratio, tab_visible, recently_active, accepted_duration_ms, qualified ) values ( ${event.id}, ${visitId}, ${event.eventType}, ${slideId}, ${event.eventAt}, ${event.sequence}, ${event.visibleRatio}, ${event.tabVisible}, - ${event.recentlyActive}, ${acceptedDurationMs} + ${event.recentlyActive}, ${acceptedDurationMs}, ${qualifies} ) on conflict (idempotency_key) do nothing returning idempotency_key `; if (inserted.length === 0) continue; - const qualifies = - event.eventType === 'slide_view' && - slideId !== null && - event.visibleRatio >= 0.5 && - event.visibleDurationMs >= 1_000 && - event.tabVisible && - event.recentlyActive; if (slideId !== null) { await tx` insert into slide_engagement ( @@ -203,6 +205,7 @@ export async function ingestEngagement( ${acceptedDurationMs}, ${qualifies ? 1 : 0}, ${event.sequence}, ${event.sequence}, ${qualifies} ) on conflict (visit_id, slide_id) do update set + first_seen_at = least(slide_engagement.first_seen_at, excluded.first_seen_at), last_seen_at = greatest(slide_engagement.last_seen_at, excluded.last_seen_at), active_duration_ms = slide_engagement.active_duration_ms + excluded.active_duration_ms, view_count = slide_engagement.view_count + excluded.view_count, diff --git a/apps/api/decks/schema.integration.test.ts b/apps/api/decks/schema.integration.test.ts index a8b6ba5..b130a6f 100644 --- a/apps/api/decks/schema.integration.test.ts +++ b/apps/api/decks/schema.integration.test.ts @@ -2,6 +2,7 @@ import postgres from 'postgres'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { initialDeckSchemaMigration } from './migrations/0001-initial-deck-schema.ts'; import { viewerSessionAnalyticsExclusionMigration } from './migrations/0002-add-viewer-session-analytics-exclusion.ts'; +import { engagementEventQualificationMigration } from './migrations/0003-add-engagement-event-qualification.ts'; import { DeckMigrationHistoryError, runDeckMigrations, @@ -74,6 +75,7 @@ integration('deck database schema', () => { const expectedMigrations = [ { version: 1, name: 'initial_deck_schema' }, { version: 2, name: 'add_viewer_session_analytics_exclusion' }, + { version: 3, name: 'add_engagement_event_qualification' }, ]; const rows = await sql<{ version: number; name: string }[]>` @@ -118,13 +120,13 @@ integration('deck database schema', () => { const rows = await sql<{ count: number }[]>` select count(*)::integer as count from deck_schema_migrations `; - expect(rows).toEqual([{ count: 2 }]); + expect(rows).toEqual([{ count: 3 }]); }); it('rolls back schema and ledger changes when a migration fails', async () => { class PlannedMigrationError extends Error {} const failingMigration = { - version: 3, + version: 4, name: 'planned_failure', async up(database) { await database`create table migration_should_roll_back (id integer primary key)`; @@ -135,6 +137,7 @@ integration('deck database schema', () => { const migration = runDeckMigrations(sql, [ initialDeckSchemaMigration, viewerSessionAnalyticsExclusionMigration, + engagementEventQualificationMigration, failingMigration, ]); @@ -146,7 +149,7 @@ integration('deck database schema', () => { const ledger = await sql<{ version: number }[]>` select version from deck_schema_migrations order by version `; - expect(ledger).toEqual([{ version: 1 }, { version: 2 }]); + expect(ledger).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]); }); it('rejects divergent applied migration history', async () => { diff --git a/apps/api/decks/schema.ts b/apps/api/decks/schema.ts index 3dcf1d9..611dc70 100644 --- a/apps/api/decks/schema.ts +++ b/apps/api/decks/schema.ts @@ -1,6 +1,7 @@ import type postgres from 'postgres'; import { initialDeckSchemaMigration } from './migrations/0001-initial-deck-schema.ts'; import { viewerSessionAnalyticsExclusionMigration } from './migrations/0002-add-viewer-session-analytics-exclusion.ts'; +import { engagementEventQualificationMigration } from './migrations/0003-add-engagement-event-qualification.ts'; import { runDeckMigrations, type DeckMigration } from './schema-migrations.ts'; export type Database = ReturnType | postgres.TransactionSql; @@ -9,6 +10,7 @@ export type DatabasePool = ReturnType; const deckMigrations: readonly DeckMigration[] = [ initialDeckSchemaMigration, viewerSessionAnalyticsExclusionMigration, + engagementEventQualificationMigration, ]; export async function initDeckSchema(database: DatabasePool): Promise { diff --git a/apps/web/analytics-format.ts b/apps/web/analytics-format.ts index 176fe7d..09c6812 100644 --- a/apps/web/analytics-format.ts +++ b/apps/web/analytics-format.ts @@ -1,4 +1,29 @@ -import { html, type TemplateResult } from 'lit'; +import { html, nothing, type TemplateResult } from 'lit'; +import type { DeckAnalytics } from './deck-types.ts'; + +export function visitSequence(visit: DeckAnalytics['visits'][number]): TemplateResult { + if (visit.sequenceComplete === false) return html`Not recorded (older visit)`; + return html`
+ ${visit.slideSequence.map((slide) => slide.ordinal).join(' → ') || '—'} + ${visit.slideSequence.length === 0 + ? nothing + : html`
+ Time by slide +

Qualified views, in order.

+
    + ${visit.slideSequence.map( + (slide) => + html`
  1. + Slide ${slide.ordinal}${slide.title === null ? '' : ` · ${slide.title}`}: + ${duration(slide.activeDurationMs)} +
  2. `, + )} +
+
`} +
`; +} export function metric(label: string, value: string | number): TemplateResult { return html`
diff --git a/apps/web/analytics-sequence.test.ts b/apps/web/analytics-sequence.test.ts new file mode 100644 index 0000000..1fb433c --- /dev/null +++ b/apps/web/analytics-sequence.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { analyticsSchema } from './deck-types.ts'; + +describe('visit sequence provenance', () => { + it('preserves an explicit incomplete history marker when an older visit is parsed', () => { + const input = { + id: '00000000-0000-4000-8000-000000000001', + viewer: 'Anonymous', + identityConfidence: 'anonymous', + startedAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + linkId: '00000000-0000-4000-8000-000000000002', + linkName: 'Client', + senderId: '00000000-0000-4000-8000-000000000003', + senderEmail: 'owner@example.test', + revisionNumber: 1, + totalActiveTimeMs: 5000, + viewedSlides: 2, + completion: 2 / 3, + furthestSlide: 2, + lastSlide: null, + sequenceComplete: false, + slideSequence: [], + }; + + const visit = analyticsSchema.shape.visits.element.parse(input); + + expect(visit).toMatchObject({ + sequenceComplete: false, + totalActiveTimeMs: 5000, + viewedSlides: 2, + }); + }); +}); diff --git a/apps/web/analytics-visit-detail.test.ts b/apps/web/analytics-visit-detail.test.ts new file mode 100644 index 0000000..007eb39 --- /dev/null +++ b/apps/web/analytics-visit-detail.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import { render } from 'lit'; +import { describe, expect, it, vi } from 'vitest'; +import { renderAnalyticsPanel } from './deck-analytics-view.ts'; +import type { DeckAnalytics } from './deck-types.ts'; + +const visit: DeckAnalytics['visits'][number] = { + id: crypto.randomUUID(), + viewer: 'Anonymous', + identityConfidence: 'anonymous', + startedAt: '2026-09-22T12:00:00Z', + lastActivityAt: '2026-09-22T12:00:10Z', + linkId: crypto.randomUUID(), + linkName: 'Customer', + senderId: crypto.randomUUID(), + senderEmail: 'owner@example.test', + revisionNumber: 1, + totalActiveTimeMs: 10_000, + viewedSlides: 2, + completion: 2 / 3, + furthestSlide: 2, + lastSlide: 1, + sequenceComplete: true, + slideSequence: [ + { + slideId: crypto.randomUUID(), + ordinal: 1, + title: 'Opening', + activeDurationMs: 3000, + viewCount: 1, + }, + { slideId: crypto.randomUUID(), ordinal: 2, title: null, activeDurationMs: 4000, viewCount: 1 }, + { + slideId: crypto.randomUUID(), + ordinal: 1, + title: 'Opening', + activeDurationMs: 3000, + viewCount: 1, + }, + ], +}; + +function overview(currentVisit: typeof visit): HTMLElement { + const analytics: DeckAnalytics = { + owner: { id: currentVisit.senderId, email: currentVisit.senderEmail }, + overview: { + totalVisits: 1, + uniqueViewers: 1, + lastViewed: currentVisit.lastActivityAt, + averageActiveTimeMs: 10_000, + averageCompletion: 2 / 3, + topSlide: null, + }, + visitors: [], + slides: [], + visits: [currentVisit], + }; + const container = document.createElement('div'); + render( + renderAnalyticsPanel({ + base: analytics, + current: analytics, + view: 'overview', + filtering: false, + error: null, + onApply: vi.fn(), + onClear: vi.fn(), + onExport: vi.fn(), + }), + container, + ); + return container; +} + +describe('per-visit slide time', () => { + it('shows each ordered appearance and its own duration without replacing distinct completion', () => { + const container = overview(visit); + expect(container.querySelector('summary')?.textContent?.trim()).toBe('Time by slide'); + expect( + Array.from(container.querySelectorAll('details li')).map((item) => + item.textContent?.replace(/\s+/g, ' ').trim(), + ), + ).toEqual(['Slide 1 · Opening: 3s', 'Slide 2: 4s', 'Slide 1 · Opening: 3s']); + expect(container.querySelector('[data-label="Completion"]')?.textContent).toBe('67%'); + }); + + it('does not invent a breakdown when an older visit has incomplete history', () => { + const container = overview({ + ...visit, + sequenceComplete: false, + slideSequence: [], + lastSlide: null, + }); + expect(container.querySelector('details')).toBeNull(); + expect(container.querySelector('[data-label="Sequence"]')?.textContent).toContain( + 'Not recorded (older visit)', + ); + expect(container.querySelector('[data-label="Active"]')?.textContent).toBe('10s'); + }); +}); diff --git a/apps/web/deck-analytics-view.ts b/apps/web/deck-analytics-view.ts index f4a28db..6f13447 100644 --- a/apps/web/deck-analytics-view.ts +++ b/apps/web/deck-analytics-view.ts @@ -6,6 +6,7 @@ import { emptyAnalytics, metric, percent, + visitSequence, } from './analytics-format.ts'; import type { DeckAnalytics } from './deck-types.ts'; @@ -238,9 +239,7 @@ function renderVisitTable(analytics: DeckAnalytics): TemplateResult { ${visit.senderEmail}
${visit.linkName} - - ${visit.slideSequence.map((slide) => slide.ordinal).join(' → ') || '—'} - + ${visitSequence(visit)} ${duration(visit.totalActiveTimeMs)} ${percent(visit.completion)} ${visit.furthestSlide ?? '—'} diff --git a/apps/web/deck-sharing-editor-view.ts b/apps/web/deck-sharing-editor-view.ts index 8c08d58..f373e7c 100644 --- a/apps/web/deck-sharing-editor-view.ts +++ b/apps/web/deck-sharing-editor-view.ts @@ -81,7 +81,8 @@ export function renderSharingEditor(input: SharingEditorViewInput): TemplateResu ?disabled=${input.saving} />Allowed email
No inbox verification. Matching viewers are labeled Unverified.Anyone who knows an allowed address can open it. No inbox verification; viewers are + labeled Unverified.
@@ -94,7 +95,8 @@ export function renderSharingEditor(input: SharingEditorViewInput): TemplateResu ?disabled=${input.saving} />Authenticated viewer
Viewer signs in with an allowed email or domain.Viewer signs in with an allowed email or domain. Recommended for confidential + material.
diff --git a/apps/web/deck-types.ts b/apps/web/deck-types.ts index aedd4bd..47ee831 100644 --- a/apps/web/deck-types.ts +++ b/apps/web/deck-types.ts @@ -207,6 +207,7 @@ export const analyticsSchema = z.object({ completion: z.number(), furthestSlide: z.number().int().nullable(), lastSlide: z.number().int().nullable(), + sequenceComplete: z.boolean().optional(), slideSequence: z.array( z.object({ slideId: z.string().uuid(), diff --git a/apps/web/deck-viewer-lifecycle.test.ts b/apps/web/deck-viewer-lifecycle.test.ts new file mode 100644 index 0000000..69c370e --- /dev/null +++ b/apps/web/deck-viewer-lifecycle.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import { z } from 'zod'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ViewerDeck } from './viewer-types.ts'; + +const api = vi.hoisted(() => ({ empty: vi.fn(), json: vi.fn() })); +vi.mock('./deck-api.ts', async (importOriginal) => ({ + ...(await importOriginal()), + apiEmpty: api.empty, + apiJson: api.json, +})); + +import { EngagementTracker } from './viewer-tracking.ts'; +import './deck-viewer.ts'; + +const deck: ViewerDeck = { + deckId: crypto.randomUUID(), + deckTitle: 'Lifecycle fixture', + revisionId: crypto.randomUUID(), + revisionNumber: 1, + shareLinkId: crypto.randomUUID(), + viewerSessionId: crypto.randomUUID(), + identityConfidence: 'anonymous', + preview: false, + slides: [ + { + id: crypto.randomUUID(), + stableSlideId: 'first', + ordinal: 1, + title: null, + html: '

First slide

', + }, + ], +}; + +function pageShow(persisted: boolean) { + const event = new Event('pageshow'); + Object.defineProperty(event, 'persisted', { value: persisted }); + window.dispatchEvent(event); +} + +async function activate(viewer: HTMLElement) { + z.instanceof(EngagementTracker).parse(Reflect.get(viewer, 'tracker')).setVisibleRatio(1); + window.dispatchEvent(new Event('focus')); + await vi.advanceTimersByTimeAsync(0); +} + +describe('DeckViewer back-forward cache lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + vi.stubGlobal('innerWidth', 1280); + sessionStorage.setItem('pagent-owner-preview', 'viewer-session'); + api.empty.mockReset().mockResolvedValue(undefined); + api.json + .mockReset() + .mockImplementation(async (path: string) => + path === '/v1/viewer/deck' ? deck : { kind: 'started', visitId: crypto.randomUUID() }, + ); + }); + + afterEach(() => { + document.body.replaceChildren(); + sessionStorage.clear(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('Given a cached viewer whose visit closed, when persisted pageshow restores it, then real activity creates a fresh visit', async () => { + const viewer = document.createElement('deck-viewer'); + document.body.append(viewer); + await vi.advanceTimersByTimeAsync(0); + await activate(viewer); + window.dispatchEvent(new Event('pagehide')); + await vi.advanceTimersByTimeAsync(0); + pageShow(true); + await activate(viewer); + expect(api.json.mock.calls.filter(([path]) => path === '/v1/viewer/visits')).toHaveLength(2); + expect(api.empty.mock.calls.at(-1)?.[0]).not.toBe(api.empty.mock.calls[0]?.[0]); + }); + + it('Given an active viewer, when ordinary pageshow fires, then its existing visit is retained', async () => { + const viewer = document.createElement('deck-viewer'); + document.body.append(viewer); + await vi.advanceTimersByTimeAsync(0); + await activate(viewer); + pageShow(false); + await activate(viewer); + expect(api.json.mock.calls.filter(([path]) => path === '/v1/viewer/visits')).toHaveLength(1); + }); + + it('Given a disconnected viewer, when persisted pageshow fires, then no observer or tracker is recreated', async () => { + const viewer = document.createElement('deck-viewer'); + document.body.append(viewer); + await vi.advanceTimersByTimeAsync(0); + await activate(viewer); + viewer.remove(); + const stopped = Reflect.get(viewer, 'tracker'); + pageShow(true); + expect(Reflect.get(viewer, 'tracker')).toBe(stopped); + expect(api.json.mock.calls.filter(([path]) => path === '/v1/viewer/visits')).toHaveLength(1); + }); +}); diff --git a/apps/web/deck-viewer.ts b/apps/web/deck-viewer.ts index 5065b34..a4db760 100644 --- a/apps/web/deck-viewer.ts +++ b/apps/web/deck-viewer.ts @@ -65,6 +65,7 @@ class DeckViewer extends LitElement { window.addEventListener('keydown', this.onKeyDown); window.addEventListener('focus', this.onFocus); window.addEventListener('pagehide', this.onPageHide); + window.addEventListener('pageshow', this.onPageShow); void this.load(); } @@ -72,6 +73,7 @@ class DeckViewer extends LitElement { window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('focus', this.onFocus); window.removeEventListener('pagehide', this.onPageHide); + window.removeEventListener('pageshow', this.onPageShow); this.observer?.disconnect(); this.tracker?.stop(); super.disconnectedCallback(); @@ -250,6 +252,9 @@ class DeckViewer extends LitElement { }; private onPageHide = (): void => this.tracker?.close(); + private onPageShow = (event: PageTransitionEvent): void => { + if (event.persisted) this.startTracking(); + }; private onFocus = (): void => this.tracker?.markActivity(); private handleError(error: unknown): void { const failure = viewerFailure(error); diff --git a/apps/web/viewer-tracking-delivery.ts b/apps/web/viewer-tracking-delivery.ts new file mode 100644 index 0000000..401229f --- /dev/null +++ b/apps/web/viewer-tracking-delivery.ts @@ -0,0 +1,87 @@ +import { ApiError, apiEmpty } from './deck-api.ts'; + +export type EngagementEvent = { + readonly id: string; + readonly eventType: 'start' | 'heartbeat' | 'slide_view' | 'close'; + readonly slideId?: string; + readonly eventAt: string; + readonly sequence: number; + readonly visibleRatio: number; + readonly visibleDurationMs: number; + readonly tabVisible: boolean; + readonly recentlyActive: boolean; +}; + +type PendingEvent = { + readonly event: EngagementEvent; + readonly keepalive: boolean; + attempts: number; +}; + +const MAX_QUEUED_EVENTS = 20; +const MAX_EVENTS_PER_REQUEST = 10; +const MAX_DELIVERY_ATTEMPTS = 3; + +export class EngagementDelivery { + private visitId: string | null = null; + private queue: PendingEvent[] = []; + private inFlight = false; + + constructor( + private readonly sessionToken: string, + private readonly onForbidden: (error: ApiError) => void, + ) {} + + setVisit(visitId: string | null): void { + this.visitId = visitId; + this.queue = []; + } + + async enqueue(event: EngagementEvent, keepalive: boolean): Promise { + if (this.visitId === null) return; + if (this.queue.length >= MAX_QUEUED_EVENTS) { + if (event.eventType !== 'close') return; + this.queue.pop(); + } + this.queue.push({ event, keepalive, attempts: 0 }); + await this.deliverPending(); + } + + private async deliverPending(): Promise { + if (this.inFlight) return; + this.inFlight = true; + try { + while (this.visitId !== null && this.queue.length > 0) { + const visitId = this.visitId; + const queue = this.queue; + const batch = queue.slice(0, MAX_EVENTS_PER_REQUEST); + try { + await apiEmpty(`/v1/viewer/visits/${visitId}/events`, { + method: 'POST', + keepalive: batch.some((pending) => pending.keepalive), + headers: { 'x-viewer-session': this.sessionToken }, + body: JSON.stringify({ events: batch.map((pending) => pending.event) }), + }); + if (queue === this.queue) this.queue.splice(0, batch.length); + } catch (error) { + if (!isExpectedTrackingError(error)) throw error; + if (queue !== this.queue) continue; + if (error instanceof ApiError && [401, 403, 410].includes(error.status)) { + this.queue = []; + this.onForbidden(error); + return; + } + for (const pending of batch) pending.attempts += 1; + this.queue = this.queue.filter((pending) => pending.attempts < MAX_DELIVERY_ATTEMPTS); + return; + } + } + } finally { + this.inFlight = false; + } + } +} + +export function isExpectedTrackingError(error: unknown): error is ApiError | TypeError { + return error instanceof ApiError || error instanceof TypeError; +} diff --git a/apps/web/viewer-tracking-lifecycle.test.ts b/apps/web/viewer-tracking-lifecycle.test.ts new file mode 100644 index 0000000..f0fd46b --- /dev/null +++ b/apps/web/viewer-tracking-lifecycle.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from './deck-api.ts'; + +const api = vi.hoisted(() => ({ empty: vi.fn(), json: vi.fn() })); +vi.mock('./deck-api.ts', async (importOriginal) => ({ + ...(await importOriginal()), + apiEmpty: api.empty, + apiJson: api.json, +})); + +import { EngagementTracker } from './viewer-tracking.ts'; + +let tracker: EngagementTracker; + +describe('EngagementTracker asynchronous lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + vi.stubGlobal('innerWidth', 1280); + api.empty.mockReset().mockResolvedValue(undefined); + api.json + .mockReset() + .mockImplementation(async () => ({ kind: 'started', visitId: crypto.randomUUID() })); + tracker = new EngagementTracker({ + sessionToken: 'viewer-session', + currentSlideId: () => 'slide-one', + analyticsConsent: true, + }); + tracker.setVisibleRatio(1); + }); + + afterEach(() => { + tracker.stop(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('Given activity before visibility is observed, when the stage becomes visible, then tracking starts once without another interaction', async () => { + tracker.setVisibleRatio(0); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + expect(api.json).not.toHaveBeenCalled(); + tracker.setVisibleRatio(1); + await vi.advanceTimersByTimeAsync(1100); + expect(api.json).toHaveBeenCalledTimes(1); + expect(api.empty).toHaveBeenCalled(); + tracker.setVisibleRatio(0.75); + expect(api.json).toHaveBeenCalledTimes(1); + }); + + it('Given no recent interaction, when visibility changes, then it cannot manufacture activity or start a visit', async () => { + tracker.setVisibleRatio(0.75); + await vi.advanceTimersByTimeAsync(1100); + expect(api.json).not.toHaveBeenCalled(); + tracker.setVisibleRatio(0); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(60_001); + tracker.setVisibleRatio(1); + await vi.advanceTimersByTimeAsync(1100); + expect(api.json).not.toHaveBeenCalled(); + }); + + it.each(['stop', 'close'] as const)( + 'Given an unresolved start, when %s happens first, then the late response cannot start events or timers', + async (action) => { + let complete: ((result: { kind: 'started'; visitId: string }) => void) | undefined; + api.json.mockReturnValueOnce( + new Promise((resolve) => { + complete = resolve; + }), + ); + tracker.markActivity(); + tracker[action](); + complete?.({ kind: 'started', visitId: crypto.randomUUID() }); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.empty).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it('Given a started visit, when pagehide closes it, then no later activity sends events for the ended visit', async () => { + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + tracker.close(); + await vi.advanceTimersByTimeAsync(0); + const requestCount = api.empty.mock.calls.length; + tracker.recordSlide('slide-two'); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.empty).toHaveBeenCalledTimes(requestCount); + expect(vi.getTimerCount()).toBe(0); + }); + + it('Given the server expires a visit before the client, when activity follows a rejected batch, then one new start is attempted', async () => { + api.empty.mockRejectedValueOnce(new ApiError(410, 'unavailable', 'Unavailable')); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.json).toHaveBeenCalledTimes(1); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + expect(api.json).toHaveBeenCalledTimes(2); + expect(api.empty).toHaveBeenCalledTimes(2); + expect(api.empty.mock.calls[0]?.[0]).not.toBe(api.empty.mock.calls[1]?.[0]); + }); + + it('Given a revoked viewer session, when activity retries after an ingest rejection, then a rejected start stops further retries', async () => { + api.empty.mockRejectedValueOnce(new ApiError(410, 'unavailable', 'Unavailable')); + api.json + .mockResolvedValueOnce({ kind: 'started', visitId: crypto.randomUUID() }) + .mockRejectedValue(new ApiError(410, 'unavailable', 'Unavailable')); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + tracker.markActivity(); + tracker.recordSlide('slide-two'); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.json).toHaveBeenCalledTimes(2); + expect(api.empty).toHaveBeenCalledTimes(1); + }); + + it.each(['excluded', 'tracking_disabled'] as const)( + 'Given a %s response, when more activity occurs, then visit creation stays disabled', + async (kind) => { + api.json.mockResolvedValue({ kind }); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + tracker.recordSlide('slide-two'); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.json).toHaveBeenCalledTimes(1); + expect(api.empty).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); +}); diff --git a/apps/web/viewer-tracking-regression.test.ts b/apps/web/viewer-tracking-regression.test.ts new file mode 100644 index 0000000..7c1b212 --- /dev/null +++ b/apps/web/viewer-tracking-regression.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment happy-dom + +import { z } from 'zod'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from './deck-api.ts'; +import { deriveAcceptedInterval } from '../api/decks/analytics.ts'; + +const api = vi.hoisted(() => ({ empty: vi.fn(), json: vi.fn() })); +vi.mock('./deck-api.ts', async (importOriginal) => ({ + ...(await importOriginal()), + apiEmpty: api.empty, + apiJson: api.json, +})); + +import { EngagementTracker } from './viewer-tracking.ts'; + +const slideOne = '9d99dfda-ec83-4667-9b17-5275cc2f493b'; +const slideTwo = 'a346aa9b-a08a-4a40-ab8b-d7cf2ac80668'; +const eventSchema = z.object({ + id: z.string(), + eventType: z.string(), + slideId: z.string().optional(), + eventAt: z.coerce.date(), + sequence: z.number(), + visibleRatio: z.number(), + tabVisible: z.boolean(), + recentlyActive: z.boolean(), +}); +const batchSchema = z.object({ events: z.array(eventSchema) }); +let currentSlide: string; +let visibility: DocumentVisibilityState; +let tracker: EngagementTracker; +let startedAt: Date; + +function events() { + return api.empty.mock.calls.flatMap(([, request]) => { + const { body } = z.object({ body: z.string() }).parse(request); + return batchSchema.parse(JSON.parse(body)).events; + }); +} + +function durations() { + let boundary = startedAt; + const totals = new Map(); + for (const event of events()) { + const duration = deriveAcceptedInterval({ + ...event, + previousAcceptedAt: boundary, + serverReceivedAt: new Date(), + }); + if (event.slideId) totals.set(event.slideId, (totals.get(event.slideId) ?? 0) + duration); + if (event.tabVisible && event.recentlyActive && event.eventAt > boundary) + boundary = event.eventAt; + } + return totals; +} + +async function start() { + tracker.setVisibleRatio(1); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); +} + +describe('EngagementTracker interval and visit boundaries', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-22T12:00:00Z')); + startedAt = new Date(); + currentSlide = slideOne; + visibility = 'visible'; + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility); + vi.stubGlobal('innerWidth', 1280); + api.empty.mockReset().mockResolvedValue(undefined); + api.json + .mockReset() + .mockImplementation(async () => ({ kind: 'started', visitId: crypto.randomUUID() })); + tracker = new EngagementTracker({ + sessionToken: 'viewer-session', + currentSlideId: () => currentSlide, + analyticsConsent: true, + }); + }); + + afterEach(() => { + tracker.stop(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('Given seven seconds on one slide, when navigation precedes one second on another, then dwell stays with each slide', async () => { + await start(); + await vi.advanceTimersByTimeAsync(7_000); + currentSlide = slideTwo; + tracker.recordSlide(slideTwo); + await vi.advanceTimersByTimeAsync(1_000); + tracker.close(); + await vi.advanceTimersByTimeAsync(0); + expect(durations()).toEqual( + new Map([ + [slideOne, 7_000], + [slideTwo, 1_000], + ]), + ); + }); + + it('Given a qualified slide, when navigation selects it again, then its view count stays one', async () => { + await start(); + await vi.advanceTimersByTimeAsync(1_000); + tracker.recordSlide(slideOne); + await vi.advanceTimersByTimeAsync(1_000); + expect(events().filter((event) => event.eventType === 'slide_view')).toHaveLength(1); + }); + + it('Given half a second visible, when a hidden interval interrupts qualification, then a fresh full second is required', async () => { + await start(); + await vi.advanceTimersByTimeAsync(500); + visibility = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(500); + visibility = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(500); + expect( + events().filter((event) => event.eventType === 'slide_view' && event.tabVisible), + ).toHaveLength(0); + await vi.advanceTimersByTimeAsync(500); + expect( + events().filter((event) => event.eventType === 'slide_view' && event.tabVisible), + ).toHaveLength(1); + }); + + it('Given a hidden tab, when it becomes visible again, then the hidden gap is not active time', async () => { + await start(); + await vi.advanceTimersByTimeAsync(2_000); + visibility = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(6_000); + visibility = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(2_000); + expect(durations().get(slideOne)).toBe(4_000); + }); + + it('Given half a second visible, when slide visibility drops below half, then qualification restarts at the threshold', async () => { + await start(); + await vi.advanceTimersByTimeAsync(500); + tracker.setVisibleRatio(0.4); + await vi.advanceTimersByTimeAsync(500); + tracker.setVisibleRatio(0.5); + await vi.advanceTimersByTimeAsync(500); + expect(events().filter((event) => event.eventType === 'slide_view')).toHaveLength(0); + await vi.advanceTimersByTimeAsync(500); + expect(events().filter((event) => event.eventType === 'slide_view')).toHaveLength(1); + expect(durations().get(slideOne)).toBe(1_500); + }); + + it('Given a sixty-second idle pause, when navigation resumes, then the new slide receives only its own second', async () => { + await start(); + await vi.advanceTimersByTimeAsync(65_000); + currentSlide = slideTwo; + tracker.recordSlide(slideTwo); + await vi.advanceTimersByTimeAsync(1_000); + expect(durations().get(slideTwo)).toBe(1_000); + }); + + it('Given an expired visit, when genuine activity returns, then a new visit starts with a reset sequence', async () => { + await start(); + await vi.advanceTimersByTimeAsync(31 * 60_000 + 1); + expect(api.json).toHaveBeenCalledTimes(1); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(1_000); + expect(api.json).toHaveBeenCalledTimes(2); + expect( + events() + .filter((event) => event.eventType === 'start') + .map((event) => event.sequence), + ).toEqual([1, 1]); + }); + + it.each(['success', 'failure'])( + 'Given an old in-flight delivery, when activity rolls over before its %s, then old completion cannot consume the new start', + async (result) => { + let complete: (() => void) | undefined; + api.empty.mockReturnValueOnce( + new Promise((resolve, reject) => { + complete = () => (result === 'success' ? resolve() : reject(new TypeError('offline'))); + }), + ); + await start(); + await vi.advanceTimersByTimeAsync(31 * 60_000 + 1); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + complete?.(); + await vi.advanceTimersByTimeAsync(0); + expect(api.json).toHaveBeenCalledTimes(2); + expect(api.empty.mock.calls).toHaveLength(2); + expect(api.empty.mock.calls[0]?.[0]).not.toBe(api.empty.mock.calls[1]?.[0]); + expect(events().map((event) => [event.eventType, event.sequence])).toEqual([ + ['start', 1], + ['start', 1], + ]); + }, + ); + + it('Given a temporary visit-start failure, when the next activity arrives, then start retries without an unhandled rejection', async () => { + api.json.mockRejectedValueOnce(new ApiError(503, 'unavailable', 'Unavailable')); + await start(); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.json).toHaveBeenCalledTimes(1); + tracker.markActivity(); + await vi.advanceTimersByTimeAsync(0); + expect(api.json).toHaveBeenCalledTimes(2); + expect(events().map((event) => event.eventType)).toEqual(['start']); + }); +}); diff --git a/apps/web/viewer-tracking.test.ts b/apps/web/viewer-tracking.test.ts index 2bd7d81..dd363e7 100644 --- a/apps/web/viewer-tracking.test.ts +++ b/apps/web/viewer-tracking.test.ts @@ -162,7 +162,7 @@ describe('EngagementTracker retry queue', () => { eventsBeforePageHide .filter((event) => event.eventType === 'heartbeat') .map((event) => event.tabVisible), - ).toEqual([false, true]); + ).toEqual([true, false, true, true]); window.dispatchEvent(new Event('pagehide')); window.dispatchEvent(new Event('pagehide')); diff --git a/apps/web/viewer-tracking.ts b/apps/web/viewer-tracking.ts index ecd0711..6be2c30 100644 --- a/apps/web/viewer-tracking.ts +++ b/apps/web/viewer-tracking.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; -import { ApiError, apiEmpty, apiJson } from './deck-api.ts'; +import { ApiError, apiJson } from './deck-api.ts'; +import { + EngagementDelivery, + isExpectedTrackingError, + type EngagementEvent, +} from './viewer-tracking-delivery.ts'; const startVisitSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('started'), visitId: z.string().uuid() }), @@ -13,35 +18,14 @@ type TrackerInput = { readonly analyticsConsent: boolean; }; -type EngagementEvent = { - readonly id: string; - readonly eventType: 'start' | 'heartbeat' | 'slide_view' | 'close'; - readonly slideId?: string; - readonly eventAt: string; - readonly sequence: number; - readonly visibleRatio: number; - readonly visibleDurationMs: number; - readonly tabVisible: boolean; - readonly recentlyActive: boolean; -}; - type QueuedEvent = { readonly eventType: EngagementEvent['eventType']; readonly slideId?: string; readonly visibleDurationMs: number; readonly keepalive?: boolean; + readonly visibleRatio?: number; }; -type PendingEvent = { - readonly event: EngagementEvent; - readonly keepalive: boolean; - attempts: number; -}; - -const MAX_QUEUED_EVENTS = 20; -const MAX_EVENTS_PER_REQUEST = 10; -const MAX_DELIVERY_ATTEMPTS = 3; - export class EngagementTracker { private readonly sessionToken: string; private readonly currentSlideId: () => string | null; @@ -49,18 +33,29 @@ export class EngagementTracker { private visitId: string | null = null; private sequence = 0; private visibleRatio = 0; + private hasInteracted = false; private lastActivityAt = Date.now(); + private lastActiveEventAt = Date.now(); + private activeSlideId: string | null; + private slideQualified = false; + private tabVisible = document.visibilityState === 'visible'; + private stopped = false; private heartbeat: ReturnType | null = null; private slideTimer: ReturnType | null = null; - private queue: PendingEvent[] = []; + private readonly delivery: EngagementDelivery; private starting = false; - private deliveryInFlight = false; private closeRecorded = false; constructor(input: TrackerInput) { this.sessionToken = input.sessionToken; this.currentSlideId = input.currentSlideId; this.analyticsConsent = input.analyticsConsent; + this.delivery = new EngagementDelivery(input.sessionToken, (error) => { + if (error.status === 410) this.resetVisit(); + else this.stop(); + }); + this.activeSlideId = input.currentSlideId(); + document.addEventListener('visibilitychange', this.onVisibilityChange); } setConsent(consent: boolean): void { @@ -68,43 +63,137 @@ export class EngagementTracker { } setVisibleRatio(ratio: number): void { - this.visibleRatio = Math.max(0, Math.min(1, ratio)); + const nextRatio = Math.max(0, Math.min(1, ratio)); + if (this.visibleRatio >= 0.5 !== nextRatio >= 0.5) { + this.flushInterval(); + this.cancelSlideTimer(); + } + this.visibleRatio = nextRatio; + void this.start(); + this.qualifySlide(); } markActivity(): void { + if (this.stopped) return; + if (this.visitId !== null && Date.now() - this.lastActiveEventAt >= 30 * 60_000) { + this.resetVisit(); + } + const resuming = Date.now() - this.lastActivityAt > 60_000; this.lastActivityAt = Date.now(); - if (this.visibleRatio >= 0.5 && this.visitId === null) void this.start(); + this.hasInteracted = true; + if (resuming) this.flushInterval(0); + void this.start(); + this.qualifySlide(); } recordSlide(slideId: string): void { + if (slideId !== this.activeSlideId) { + this.flushInterval(); + this.activeSlideId = slideId; + this.slideQualified = false; + this.cancelSlideTimer(); + } this.markActivity(); - if (this.slideTimer !== null) clearTimeout(this.slideTimer); + } + + private qualifySlide(): void { + const slideId = this.activeSlideId; + if ( + this.stopped || + this.visitId === null || + this.slideTimer !== null || + this.slideQualified || + slideId === null || + this.visibleRatio < 0.5 || + !this.tabVisible + ) + return; this.slideTimer = setTimeout(() => { - if (this.currentSlideId() !== slideId || this.visibleRatio < 0.5) return; + this.slideTimer = null; + if ( + this.currentSlideId() !== slideId || + this.visibleRatio < 0.5 || + !this.tabVisible || + Date.now() - this.lastActivityAt > 60_000 + ) + return; + this.slideQualified = true; void this.enqueue({ eventType: 'slide_view', slideId, visibleDurationMs: 1_000 }); }, 1_000); } close(): void { - if (this.visitId === null || this.closeRecorded) return; + if (this.closeRecorded) return; this.closeRecorded = true; void this.enqueue({ eventType: 'close', - slideId: this.currentSlideId() ?? undefined, + slideId: this.activeSlideId ?? undefined, visibleDurationMs: 0, keepalive: true, }); + this.stop(); } stop(): void { + this.stopped = true; + document.removeEventListener('visibilitychange', this.onVisibilityChange); + this.clearTimers(); + } + + private clearTimers(): void { if (this.heartbeat !== null) clearInterval(this.heartbeat); - if (this.slideTimer !== null) clearTimeout(this.slideTimer); this.heartbeat = null; + this.cancelSlideTimer(); + } + + private resetVisit(): void { + this.clearTimers(); + this.visitId = null; + this.hasInteracted = false; + this.delivery.setVisit(null); + this.sequence = 0; + this.slideQualified = false; + this.closeRecorded = false; + } + + private cancelSlideTimer(): void { + if (this.slideTimer !== null) clearTimeout(this.slideTimer); this.slideTimer = null; } + private onVisibilityChange = (): void => { + const visible = document.visibilityState === 'visible'; + if (visible === this.tabVisible) return; + if (!visible) this.flushInterval(); + this.tabVisible = visible; + this.cancelSlideTimer(); + if (visible) { + this.flushInterval(0); + void this.start(); + } + this.qualifySlide(); + }; + + private flushInterval(visibleRatio = this.visibleRatio): void { + void this.enqueue({ + eventType: 'heartbeat', + slideId: this.activeSlideId ?? undefined, + visibleDurationMs: 0, + visibleRatio, + }); + } + private async start(): Promise { - if (this.starting || this.visitId !== null) return; + if ( + this.stopped || + this.starting || + this.visitId !== null || + !this.hasInteracted || + Date.now() - this.lastActivityAt > 60_000 || + this.visibleRatio < 0.5 || + !this.tabVisible + ) + return; this.starting = true; try { const result = await apiJson('/v1/viewer/visits', startVisitSchema, { @@ -119,77 +208,53 @@ export class EngagementTracker { countryCode: null, }), }); - if (result.kind !== 'started') return; + if (this.stopped) return; + if (result.kind !== 'started') { + this.stop(); + return; + } this.visitId = result.visitId; - await this.enqueue({ + this.delivery.setVisit(result.visitId); + this.activeSlideId = this.currentSlideId(); + void this.enqueue({ eventType: 'start', slideId: this.currentSlideId() ?? undefined, visibleDurationMs: 0, + visibleRatio: 0, }); - const slideId = this.currentSlideId(); - if (slideId !== null) this.recordSlide(slideId); + this.qualifySlide(); this.heartbeat = setInterval(() => { + if (Date.now() - this.lastActiveEventAt >= 30 * 60_000) return; void this.enqueue({ eventType: 'heartbeat', - slideId: this.currentSlideId() ?? undefined, + slideId: this.activeSlideId ?? undefined, visibleDurationMs: 10_000, }); }, 10_000); + } catch (error) { + if (!isExpectedTrackingError(error)) throw error; + if (error instanceof ApiError && [401, 403, 410].includes(error.status)) this.stop(); } finally { this.starting = false; } } private async enqueue(input: QueuedEvent): Promise { - if (this.visitId === null) return; - if (this.queue.length >= MAX_QUEUED_EVENTS) { - if (input.eventType !== 'close') return; - this.queue.pop(); - } + if (this.visitId === null || this.stopped) return; const event: EngagementEvent = { id: crypto.randomUUID(), eventType: input.eventType, ...(input.slideId === undefined ? {} : { slideId: input.slideId }), eventAt: new Date().toISOString(), sequence: ++this.sequence, - visibleRatio: this.visibleRatio, + visibleRatio: input.visibleRatio ?? this.visibleRatio, visibleDurationMs: input.visibleDurationMs, - tabVisible: document.visibilityState === 'visible', + tabVisible: this.tabVisible, recentlyActive: Date.now() - this.lastActivityAt <= 60_000, }; - this.queue.push({ event, keepalive: input.keepalive === true, attempts: 0 }); - await this.deliverPending(); + if (event.tabVisible && event.recentlyActive) this.lastActiveEventAt = Date.now(); + await this.delivery.enqueue(event, input.keepalive === true); } - - private async deliverPending(): Promise { - if (this.deliveryInFlight || this.visitId === null) return; - this.deliveryInFlight = true; - try { - while (this.queue.length > 0) { - const batch = this.queue.slice(0, MAX_EVENTS_PER_REQUEST); - try { - await apiEmpty(`/v1/viewer/visits/${this.visitId}/events`, { - method: 'POST', - keepalive: batch.some((pending) => pending.keepalive), - headers: { 'x-viewer-session': this.sessionToken }, - body: JSON.stringify({ events: batch.map((pending) => pending.event) }), - }); - this.queue.splice(0, batch.length); - } catch (error) { - if (!isExpectedDeliveryError(error)) throw error; - for (const pending of batch) pending.attempts += 1; - this.queue = this.queue.filter((pending) => pending.attempts < MAX_DELIVERY_ATTEMPTS); - return; - } - } - } finally { - this.deliveryInFlight = false; - } - } -} - -function isExpectedDeliveryError(error: unknown): error is ApiError | TypeError { - return error instanceof ApiError || error instanceof TypeError; } function deviceClass(): 'mobile' | 'tablet' | 'desktop' { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5a4965b..30de2c6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -43,9 +43,22 @@ stories rather than relying on a successful build alone: analytics reads reject unauthenticated callers. - An authenticated presentation can be revised, shared, viewed, and read for analytics. +- Read slide 1, advance to slide 2, then return to slide 1. Check that time + remains attributed to the slide actually read, sequence is `1 → 2 → 1`, and + last slide/drop-off is 1 without increasing distinct-slide completion. +- Backgrounding and sixty-second inactivity pause active time. Returning in + the same tab after thirty minutes starts a new visit. - `/admin` enforces workspace-admin access and does not reveal private page content or viewer-level analytics without an explicit grant. +The analytics qualification migration is additive and runs at API startup. +Historical raw events did not retain the one-second qualification result, so +their exact appearance order cannot be reconstructed reliably. Such visits +return `sequenceComplete: false`, an empty sequence, and no asserted last slide +or exit; the UI labels their sequence as unrecorded. Existing slide totals, +distinct completion, and immutable revision history remain intact. Do not +backfill guessed order or claim previously misattributed dwell was repaired. + ### 2. CI gate Confirm the GitHub Actions CI workflow is green on the PR targeting `main`. diff --git a/tests/e2e/engagement-readiness.spec.ts b/tests/e2e/engagement-readiness.spec.ts new file mode 100644 index 0000000..e2c1d3d --- /dev/null +++ b/tests/e2e/engagement-readiness.spec.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; +import { expect, request, test } from '@playwright/test'; +import type { APIRequestContext, Page } from '@playwright/test'; +import { z } from 'zod'; +import { SESSION_COOKIE_NAME } from '../../apps/api/auth/middleware.ts'; +import { createSession } from '../../apps/api/auth/session.ts'; +import * as db from '../../apps/api/db.ts'; + +const apiUrl = process.env.E2E_API_URL ?? 'http://127.0.0.1:8787'; +const publishedSchema = z.object({ deckId: z.string().uuid() }); +const linkSchema = z.object({ token: z.string() }); +const visitSchema = z.object({ kind: z.literal('started'), visitId: z.string().uuid() }); +const analyticsSchema = z.object({ + visits: z.array( + z.object({ + id: z.string(), + lastSlide: z.number().nullable(), + completion: z.number(), + slideSequence: z.array(z.object({ ordinal: z.number(), activeDurationMs: z.number() })), + }), + ), + slides: z.array( + z.object({ ordinal: z.number(), totalActiveTimeMs: z.number(), exits: z.number() }), + ), +}); + +let ownerApi: APIRequestContext; +let ownerSession: string; + +test.beforeAll(async () => { + await db.init(z.string().url().parse(process.env.DATABASE_URL)); + const owner = await db.upsertUser({ + email: `engagement-readiness-${randomUUID()}@example.test`, + handle: `engagement-readiness-${randomUUID()}`, + name: 'Analytics readiness', + avatarUrl: null, + }); + ownerSession = await createSession(owner.id); + ownerApi = await request.newContext({ + baseURL: apiUrl, + extraHTTPHeaders: { Cookie: `${SESSION_COOKIE_NAME}=${ownerSession}` }, + }); +}); + +test.afterAll(async () => { + await ownerApi?.dispose(); + await db.shutdown(); +}); + +async function presentation(): Promise<{ readonly deckId: string; readonly token: string }> { + const response = await ownerApi.post('/v1/decks', { + data: { + title: 'Engagement readiness', + slides: [1, 2, 3].map((ordinal) => ({ + id: `readiness-${ordinal}`, + title: `Slide ${ordinal}`, + html: `

Slide ${ordinal}

Readiness narrative

`, + })), + }, + }); + expect(response.status()).toBe(201); + const { deckId } = publishedSchema.parse(await response.json()); + const link = await ownerApi.post(`/v1/decks/${deckId}/share-links`, { + data: { + name: 'Anyone readiness', + access_mode: 'anyone', + allowed_emails: [], + allowed_domains: [], + }, + }); + expect(link.status()).toBe(201); + return { deckId, ...linkSchema.parse(await link.json()) }; +} + +function qualifyingResponse(page: Page) { + return page.waitForResponse( + (response) => + response.request().method() === 'POST' && + /\/v1\/viewer\/visits\/[^/]+\/events$/.test(response.url()) && + response.request().postData()?.includes('"slide_view"') === true, + ); +} + +test('reading and revisiting slides preserves dwell, actual sequence, and drop-off in the owner UI', async ({ + browser, +}) => { + const fixture = await presentation(); + const viewer = await browser.newContext(); + const owner = await browser.newContext(); + try { + const page = await viewer.newPage(); + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto(`/share/${fixture.token}`); + await expect(page.getByRole('status')).toHaveText('Slide 1 of 3'); + const first = qualifyingResponse(page); + await page.keyboard.press('Home'); + expect((await first).status()).toBe(202); + // Measured reading dwell, not a wait for asynchronous readiness. + await page.waitForTimeout(6000); + const second = qualifyingResponse(page); + await page.getByRole('button', { name: 'Next →' }).click(); + expect((await second).status()).toBe(202); + const beforeReturn = analyticsSchema.parse( + await (await ownerApi.get(`/v1/decks/${fixture.deckId}/analytics`)).json(), + ); + expect( + beforeReturn.slides.find((slide) => slide.ordinal === 1)?.totalActiveTimeMs, + ).toBeGreaterThan( + beforeReturn.slides.find((slide) => slide.ordinal === 2)?.totalActiveTimeMs ?? Infinity, + ); + const returned = qualifyingResponse(page); + await page.getByRole('button', { name: '← Previous' }).click(); + expect((await returned).status()).toBe(202); + const result = analyticsSchema.parse( + await (await ownerApi.get(`/v1/decks/${fixture.deckId}/analytics`)).json(), + ); + const visit = result.visits[0]; + expect(visit?.lastSlide).toBe(1); + expect(visit?.slideSequence.map((slide) => slide.ordinal)).toEqual([1, 2, 1]); + expect(visit?.completion).toBeCloseTo(2 / 3); + expect(result.slides.find((slide) => slide.ordinal === 1)?.exits).toBe(1); + expect(result.slides.find((slide) => slide.ordinal === 2)?.exits).toBe(0); + await owner.addCookies([{ name: SESSION_COOKIE_NAME, value: ownerSession, url: apiUrl }]); + const ownerPage = await owner.newPage(); + await ownerPage.goto(`/pages/${fixture.deckId}#overview`); + await expect(ownerPage.getByText('1 → 2 → 1', { exact: true })).toBeVisible(); + await expect(ownerPage.locator('td[data-label="Last"]')).toHaveText('1'); + const times = ownerPage.locator('details'); + await times.getByText('Time by slide', { exact: true }).focus(); + await ownerPage.keyboard.press('Enter'); + await expect(times).toHaveAttribute('open', ''); + await expect(times.locator('li')).toHaveCount(3); + await expect(times.locator('li').first()).toContainText('Slide 1 · Slide 1:'); + await expect(times.locator('li').nth(1)).toContainText('Slide 2 · Slide 2:'); + await expect(times.locator('li').last()).toContainText('Slide 1 · Slide 1:'); + expect(pageErrors).toEqual([]); + } finally { + await viewer.close(); + await owner.close(); + } +}); + +test('interaction before the first visibility observation still starts a qualified visit', async ({ + page, +}) => { + const fixture = await presentation(); + await page.addInitScript(() => { + const NativeObserver = window.IntersectionObserver; + window.IntersectionObserver = class extends NativeObserver { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + super((entries, observer) => { + window.addEventListener('release-observation', () => callback(entries, observer), { + once: true, + }); + document.documentElement.dataset.observationReady = 'true'; + }, options); + } + }; + }); + await page.goto(`/share/${fixture.token}`); + await expect(page.getByRole('status')).toHaveText('Slide 1 of 3'); + await expect(page.locator('html')).toHaveAttribute('data-observation-ready', 'true'); + await page.keyboard.press('Home'); + const qualified = qualifyingResponse(page); + await page.evaluate(() => window.dispatchEvent(new Event('release-observation'))); + expect((await qualified).ok()).toBe(true); +}); + +test('a same-tab return after thirty minutes starts a new visit without blocking navigation', async ({ + page, +}) => { + const fixture = await presentation(); + await page.clock.install({ time: new Date() }); + await page.goto(`/share/${fixture.token}`); + await expect(page.getByRole('status')).toHaveText('Slide 1 of 3'); + const startedResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && response.url().endsWith('/v1/viewer/visits'), + ); + await page.keyboard.press('Home'); + const started = visitSchema.parse(await (await startedResponse).json()); + const qualified = qualifyingResponse(page); + await page.clock.runFor(1100); + await qualified; + await db.database()`update visits set last_activity_at = now() - interval '31 minutes' where id = ${started.visitId}`; + await page.clock.fastForward(31 * 60_000); + const resumedResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && response.url().endsWith('/v1/viewer/visits'), + ); + await page.getByRole('button', { name: 'Next →' }).click(); + await expect(page.getByRole('status')).toHaveText('Slide 2 of 3'); + const resumed = visitSchema.parse(await (await resumedResponse).json()); + expect(resumed.visitId).not.toBe(started.visitId); + const result = analyticsSchema.parse( + await (await ownerApi.get(`/v1/decks/${fixture.deckId}/analytics`)).json(), + ); + expect(result.visits).toHaveLength(2); +});