Skip to content

Commit 3c09e6e

Browse files
feat(audience): typed track() surface for v1 event catalogue
Adds the v1 typed event surface to @imtbl/audience: 11 events (sign_up, sign_in, wishlist_add, wishlist_remove, purchase, game_launch, progression, resource, email_acquired, game_page_viewed, link_clicked), each with a property interface and compile-time enforcement on the track() call site. Uses a single generic track<E extends string>(event, ...args) signature backed by a PropsFor<E> conditional type. Traditional overloads fall through to the string catch-all when the properties object is incomplete (TypeScript picks the more-specific overload's first arg match, sees the second arg is not assignable, then silently picks the less-specific overload that accepts any object). The generic approach does not fall through and produces a real error for missing, wrong-type, and extra properties. A variadic-tuple conditional ({} extends PropsFor<E>) makes the properties argument required for events that have required fields and optional for events where every field is optional. Extended events (email_acquired, game_page_viewed, link_clicked) carry over from immutable/play#5151 with two normalisations: - isLoggedIn is dropped from all three shapes. The SDK already knows logged-in state via identify() + consent level; pipeline consumers should derive is_logged_in from userId IS NOT NULL on the events table rather than accept a duplicated property from every call site. - source is a free string. Play keeps its AudienceSource enum locally and passes its values into the SDK as strings. Runtime is unchanged modulo one type cast in the enqueue call (widening PropsFor<E> to Record<string, unknown>). Bundle size unchanged. The track() method body's runtime logic is identical to baseline. Type safety is asserted by a new sdk.test-d.ts file that uses @ts-expect-error to prove negative cases fail typecheck — missing required properties, extra properties, wrong value types, wrong enum values, and zero-argument calls on required-props events. The file is included in tsc but skipped by Jest (its .test-d.ts suffix does not match the default test pattern). Runtime coverage: five new it blocks in sdk.test.ts exercise sign_up, game_launch, progression, resource, and wishlist_add end-to-end through the queue flush path. 54 tests passing (up from 49). Independent of the feat/audience-web-sdk-demo branch and the Phase 0 audience-foundations work. No shared files; merges in any order. Spec: docs/superpowers/specs/2026-04-13-websdk-typed-events-design.md Plan: docs/superpowers/plans/2026-04-13-websdk-typed-events.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc9f900 commit 3c09e6e

5 files changed

Lines changed: 397 additions & 3 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export const AudienceEvents = {
2+
SIGN_UP: 'sign_up',
3+
SIGN_IN: 'sign_in',
4+
WISHLIST_ADD: 'wishlist_add',
5+
WISHLIST_REMOVE: 'wishlist_remove',
6+
PURCHASE: 'purchase',
7+
8+
GAME_LAUNCH: 'game_launch',
9+
PROGRESSION: 'progression',
10+
RESOURCE: 'resource',
11+
12+
EMAIL_ACQUIRED: 'email_acquired',
13+
GAME_PAGE_VIEWED: 'game_page_viewed',
14+
LINK_CLICKED: 'link_clicked',
15+
} as const;
16+
17+
export interface SignUpProperties {
18+
method?: string;
19+
}
20+
21+
export interface SignInProperties {
22+
method?: string;
23+
}
24+
25+
export interface WishlistAddProperties {
26+
gameId: string;
27+
source?: string;
28+
platform?: string;
29+
}
30+
31+
export interface WishlistRemoveProperties {
32+
gameId: string;
33+
}
34+
35+
export interface PurchaseProperties {
36+
currency: string;
37+
value: number;
38+
itemId?: string;
39+
itemName?: string;
40+
quantity?: number;
41+
transactionId?: string;
42+
}
43+
44+
export interface GameLaunchProperties {
45+
platform?: string;
46+
version?: string;
47+
buildId?: string;
48+
}
49+
50+
export type ProgressionStatus = 'start' | 'complete' | 'fail';
51+
52+
export interface ProgressionProperties {
53+
status: ProgressionStatus;
54+
world?: string;
55+
level?: string;
56+
stage?: string;
57+
score?: number;
58+
durationSec?: number;
59+
}
60+
61+
export type ResourceFlow = 'sink' | 'source';
62+
63+
export interface ResourceProperties {
64+
flow: ResourceFlow;
65+
currency: string;
66+
amount: number;
67+
itemType?: string;
68+
itemId?: string;
69+
}
70+
71+
export interface EmailAcquiredProperties {
72+
source?: string;
73+
}
74+
75+
export interface GamePageViewedProperties {
76+
gameId: string;
77+
gameName?: string;
78+
slug?: string;
79+
}
80+
81+
export interface LinkClickedProperties {
82+
url: string;
83+
label?: string;
84+
source?: string;
85+
gameId?: string;
86+
}
87+
88+
interface EventPropsMap {
89+
sign_up: SignUpProperties;
90+
sign_in: SignInProperties;
91+
wishlist_add: WishlistAddProperties;
92+
wishlist_remove: WishlistRemoveProperties;
93+
purchase: PurchaseProperties;
94+
game_launch: GameLaunchProperties;
95+
progression: ProgressionProperties;
96+
resource: ResourceProperties;
97+
email_acquired: EmailAcquiredProperties;
98+
game_page_viewed: GamePageViewedProperties;
99+
link_clicked: LinkClickedProperties;
100+
}
101+
102+
export type PropsFor<E extends string> =
103+
E extends keyof EventPropsMap ? EventPropsMap[E] : Record<string, unknown>;

packages/audience/sdk/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
export { Audience } from './sdk';
2+
export { AudienceEvents } from './events';
23
export { IdentityType } from '@imtbl/audience-core';
34
export type { AudienceConfig } from './types';
5+
export type {
6+
EmailAcquiredProperties,
7+
GameLaunchProperties,
8+
GamePageViewedProperties,
9+
LinkClickedProperties,
10+
ProgressionProperties,
11+
ProgressionStatus,
12+
PurchaseProperties,
13+
ResourceFlow,
14+
ResourceProperties,
15+
SignInProperties,
16+
SignUpProperties,
17+
WishlistAddProperties,
18+
WishlistRemoveProperties,
19+
} from './events';
420
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Audience, AudienceEvents } from './index';
2+
/* eslint-disable @typescript-eslint/no-unused-vars */
3+
import type {
4+
EmailAcquiredProperties,
5+
GameLaunchProperties,
6+
GamePageViewedProperties,
7+
LinkClickedProperties,
8+
ProgressionProperties,
9+
ProgressionStatus,
10+
PurchaseProperties,
11+
ResourceFlow,
12+
ResourceProperties,
13+
SignInProperties,
14+
SignUpProperties,
15+
WishlistAddProperties,
16+
WishlistRemoveProperties,
17+
} from './events';
18+
/* eslint-enable @typescript-eslint/no-unused-vars */
19+
20+
declare const sdk: Audience;
21+
22+
// ---- Happy path ----
23+
24+
sdk.track('sign_up');
25+
sdk.track('sign_up', { method: 'email' });
26+
sdk.track('sign_in');
27+
sdk.track('sign_in', { method: 'passport' });
28+
sdk.track('wishlist_add', { gameId: 'abc' });
29+
sdk.track('wishlist_add', { gameId: 'abc', source: 'game_page', platform: 'steam' });
30+
sdk.track('wishlist_remove', { gameId: 'abc' });
31+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
32+
sdk.track('purchase', {
33+
currency: 'USD',
34+
value: 9.99,
35+
itemId: 'sku_1',
36+
itemName: 'Gold Pack',
37+
quantity: 2,
38+
transactionId: 'txn_42',
39+
});
40+
sdk.track('game_launch');
41+
sdk.track('game_launch', { platform: 'webgl', version: '1.2.0', buildId: 'ci-42' });
42+
sdk.track('progression', { status: 'start' });
43+
sdk.track('progression', { status: 'complete', world: 'tutorial' });
44+
sdk.track('progression', {
45+
status: 'fail',
46+
world: 'forest',
47+
level: '5',
48+
stage: 'boss',
49+
score: 420,
50+
durationSec: 87,
51+
});
52+
sdk.track('resource', { flow: 'sink', currency: 'gold', amount: 50 });
53+
sdk.track('resource', {
54+
flow: 'source',
55+
currency: 'USD',
56+
amount: 9.99,
57+
itemType: 'iap',
58+
itemId: 'sku_1',
59+
});
60+
sdk.track('email_acquired');
61+
sdk.track('email_acquired', { source: 'linked_account_steam' });
62+
sdk.track('game_page_viewed', { gameId: 'abc' });
63+
sdk.track('game_page_viewed', { gameId: 'abc', gameName: 'Devilfish', slug: 'devilfish' });
64+
sdk.track('link_clicked', { url: 'https://example.com' });
65+
sdk.track('link_clicked', {
66+
url: 'https://example.com',
67+
label: 'Play Now',
68+
source: 'game_page',
69+
gameId: 'abc',
70+
});
71+
72+
// ---- AudienceEvents constants ----
73+
74+
sdk.track(AudienceEvents.SIGN_UP, { method: 'email' });
75+
sdk.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
76+
sdk.track(AudienceEvents.PROGRESSION, { status: 'complete' });
77+
78+
// ---- Missing required property ----
79+
80+
// @ts-expect-error — 'value' is required on PurchaseProperties
81+
sdk.track('purchase', { currency: 'USD' });
82+
83+
// @ts-expect-error — 'gameId' is required on WishlistAddProperties
84+
sdk.track('wishlist_add', {});
85+
86+
// @ts-expect-error — 'status' is required on ProgressionProperties
87+
sdk.track('progression', { world: 'tutorial' });
88+
89+
// @ts-expect-error — 'flow', 'currency', 'amount' all required
90+
sdk.track('resource', { itemType: 'iap' });
91+
92+
// @ts-expect-error — 'url' is required on LinkClickedProperties
93+
sdk.track('link_clicked', { label: 'Play Now' });
94+
95+
// @ts-expect-error — 'gameId' is required on GamePageViewedProperties
96+
sdk.track('game_page_viewed', {});
97+
98+
// ---- Zero-argument form on events with required properties ----
99+
100+
// @ts-expect-error — 'purchase' requires properties (currency + value)
101+
sdk.track('purchase');
102+
103+
// @ts-expect-error — 'wishlist_add' requires properties (gameId)
104+
sdk.track('wishlist_add');
105+
106+
// @ts-expect-error — 'wishlist_remove' requires properties (gameId)
107+
sdk.track('wishlist_remove');
108+
109+
// @ts-expect-error — 'progression' requires properties (status)
110+
sdk.track('progression');
111+
112+
// @ts-expect-error — 'resource' requires properties (flow, currency, amount)
113+
sdk.track('resource');
114+
115+
// @ts-expect-error — 'game_page_viewed' requires properties (gameId)
116+
sdk.track('game_page_viewed');
117+
118+
// @ts-expect-error — 'link_clicked' requires properties (url)
119+
sdk.track('link_clicked');
120+
121+
// ---- Unknown property ----
122+
123+
// @ts-expect-error — 'currenyc' is not a property of PurchaseProperties
124+
sdk.track('purchase', { currenyc: 'USD', value: 9.99 });
125+
126+
// @ts-expect-error — 'extra' is not a property of PurchaseProperties (all required fields present)
127+
sdk.track('purchase', { currency: 'USD', value: 9.99, extra: 1 });
128+
129+
// @ts-expect-error — 'isLoggedIn' is not a property of LinkClickedProperties
130+
sdk.track('link_clicked', { url: 'x', isLoggedIn: true });
131+
132+
// ---- Wrong value type ----
133+
134+
// @ts-expect-error — 'value' must be number
135+
sdk.track('purchase', { currency: 'USD', value: '9.99' });
136+
137+
// @ts-expect-error — 'status' must be 'start' | 'complete' | 'fail'
138+
sdk.track('progression', { status: 'done' });
139+
140+
// @ts-expect-error — 'flow' must be 'sink' | 'source'
141+
sdk.track('resource', { flow: 'both', currency: 'gold', amount: 1 });
142+
143+
// ---- Custom events and dynamic names ----
144+
145+
sdk.track('beta_key_redeemed', { source: 'influencer' });
146+
sdk.track('discord_joined');
147+
sdk.track('trailer_watched', { duration: 45, platform: 'youtube' });
148+
149+
declare const dynamicName: string;
150+
sdk.track(dynamicName, { anything: 'goes' });
151+
sdk.track(dynamicName);

0 commit comments

Comments
 (0)