Skip to content

feat(households): pick events + provider launch tracking (Phase F)#60

Merged
ParagonJenko merged 1 commit into
masterfrom
claude/wizardly-wright-Gosoq
Jun 2, 2026
Merged

feat(households): pick events + provider launch tracking (Phase F)#60
ParagonJenko merged 1 commit into
masterfrom
claude/wizardly-wright-Gosoq

Conversation

@ParagonJenko

Copy link
Copy Markdown
Member

Why

Audit against the Notion business plan turned up two missing seams in the Tonight flow:

  1. Affiliate revenue / provider deep-links (Notion §6) — ProviderBadges were decorative, so the "rev-share on signups" stream had nowhere to land.
  2. Continuous recommender evaluation (Notion §14) — we capture enjoyed thumbs post-watch, but not whether the first pick was accepted vs. swapped vs. dismissed, so the "30-second decision" KPI was unmeasurable.

These ship together because a provider click is a pick outcome. This PR adds Phase F: a single pick_events log that powers both.

What

  • pick_events table + migration (20260601000003-create-pick-events.ts, up/down)
  • PickEvent model, registered in models/index.ts with Household / User associations
  • pickEvent.service.tsrecordPickEvent + getPickEventStats (30-day window, first-pick acceptance rate, per-provider click totals)
  • providerLaunch.service.ts — resolves { tmdb_id, media_type, provider_slug, region } to the TMDb/JustWatch redirect link, throws ProviderNotAvailableError on miss
  • pickEvent.controller.ts — three member-gated endpoints:
    • POST /api/households/:id/pick-events
    • GET /api/households/:id/pick-events/stats?window_days=30
    • POST /api/households/:id/picks/:tmdbId/launch (returns deep link + records provider_launched)
  • household.controller.ts — auto-emits a proposed event after each successful pick (fire-and-forget, logged on failure)
  • TonightPicker.tsx — accept / swap / dismiss now fire events; provider badges become Watch on X launch buttons that open the redirect in a new tab
  • docs/db-schema.md updated with the new table

Out of scope (deferred)

  • TV in the picker — recommender still only calls discoverMedia({ mediaType: 'movie' }). Separate PR.
  • Retention features — streaks and rewatch surfacing (Notion §14). Separate PR.
  • Real Stripe — intentionally deferred per CLAUDE.md; mock checkout stays.

Test plan

  • npx jest — 37 suites, 292 tests green (11 new)
  • npx tsc --noEmit — backend and app.client both clean
  • npm run build:components — green
  • Prettier + ESLint via lint-staged on commit — green
  • Manual: open Tonight, get a pick, confirm pick_events rows appear for proposed/accepted/swapped/dismissed
  • Manual: click Watch on Netflix, confirm new tab opens with JustWatch redirect and a provider_launched row is written
  • Manual: hit GET /api/households/:id/pick-events/stats and verify first_pick_acceptance_rate + provider_clicks_by_slug

https://claude.ai/code/session_01RM3zxZtSjLdMFGFeNv6fq9


Generated by Claude Code

Adds the recommender-evaluation and affiliate-attribution seam that was
missing from the Tonight flow. Every pick now records its lifecycle
(proposed/accepted/swapped/dismissed) and "Watch on X" buttons resolve
the TMDb/JustWatch redirect link while logging a provider_launched event.

- new pick_events table + migration with up/down
- pickEvent.service records events and computes 30-day stats including
  first-pick acceptance rate and per-provider click counts
- providerLaunch.service resolves a provider slug + region to a deep
  link, throwing ProviderNotAvailableError when there's no match
- POST /api/households/:id/pick-events, GET .../stats, and
  POST /api/households/:id/picks/:tmdbId/launch
- pick controller auto-emits a proposed event after each successful pick
- TonightPicker wires accept/swap/dismiss to events and turns provider
  badges into launch buttons that open the redirect in a new tab
@ParagonJenko ParagonJenko marked this pull request as ready for review June 2, 2026 07:34
Copilot AI review requested due to automatic review settings June 2, 2026 07:34
@ParagonJenko ParagonJenko merged commit 6476c71 into master Jun 2, 2026
8 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Phase F pick-event logging to measure “30-second decision” outcomes (accepted/swapped/dismissed) and track provider launch clicks for affiliate attribution, spanning DB schema, backend APIs/services, and Tonight UI instrumentation.

Changes:

  • Introduces pick_events persistence (migration + Sequelize model + associations) and documents the schema.
  • Adds backend services/controllers/routes to record events, compute 30-day stats, and resolve provider launch deep-links.
  • Updates Tonight picker UI and client API layer to emit pick outcome events and launch-provider events.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
docs/db-schema.md Documents the new pick_events table and indexes.
backend/src/db/migrations/20260601000003-create-pick-events.ts Creates/drops pick_events with enums + indexes.
backend/src/models/PickEvent.ts Adds Sequelize model for pick events.
backend/src/models/index.ts Registers PickEvent model and household/user associations.
backend/src/services/pickEvent.service.ts Implements event recording and 30-day stats aggregation.
backend/src/services/pickEvent.service.test.ts Adds unit tests for pick event service logic (includes a failing assertion as written).
backend/src/services/providerLaunch.service.ts Resolves provider availability and returns TMDb/JustWatch link.
backend/src/services/providerLaunch.service.test.ts Adds unit tests for provider launch resolution.
backend/src/controllers/pickEvent.controller.ts Adds member-gated endpoints for recording events, stats, and provider launch.
backend/src/routes/household.routes.ts Wires new pick-event and provider-launch routes under households.
backend/src/controllers/household.controller.ts Emits a backend proposed event after a successful pick (fire-and-forget).
app.client/src/services/api/households.ts Adds typed client API calls for pick events and provider launch.
app.client/src/features/tonight/TonightPicker.tsx Emits accept/swap/dismiss events and turns provider badges into launch buttons.

Comment on lines +126 to +133
const call = mockedFindAll.mock.calls[0]?.[0] as
| { where?: { occurred_at?: Record<string, Date> } }
| undefined;
const bound = call?.where?.occurred_at?.gte;
expect(bound).toBeInstanceOf(Date);
const ageMs = Date.now() - (bound as Date).getTime();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
expect(Math.abs(ageMs - sevenDaysMs)).toBeLessThan(5000);
Comment on lines +51 to +52
export const postPickEvent = async (req: Request, res: Response) => {
const householdId = req.params.id;
Comment on lines +72 to +74
if (!VALID_KINDS.includes(body.kind)) {
return res.status(400).json({ error: 'invalid_kind' });
}
Comment on lines +75 to +89
if (body.region !== undefined && !/^[A-Z]{2}$/.test(body.region)) {
return res.status(400).json({ error: 'invalid_region' });
}

const event = await recordPickEvent({
household_id: householdId,
user_id: userId,
tmdb_id: body.tmdb_id,
media_type: body.media_type,
kind: body.kind,
mood: body.mood ?? null,
minutes_budget: body.minutes_budget ?? null,
provider_slug: body.provider_slug ?? null,
region: body.region ?? null,
});
Comment on lines +165 to +175
await recordPickEvent({
household_id: householdId,
user_id: userId,
tmdb_id: tmdbId,
media_type: body.media_type,
kind: 'provider_launched',
mood: body.mood ?? null,
minutes_budget: body.minutes_budget ?? null,
provider_slug: body.provider_slug,
region,
});
Comment on lines +226 to +241
const onLaunchProvider = async (
card: RecommendationCard,
providerSlug: string
) => {
if (!household) return;
try {
const { url } = await households.launchProvider(
household.id,
card.tmdb_id,
{
provider_slug: providerSlug,
media_type: card.media_type,
mood,
minutes_budget: minutes,
}
);
Comment on lines +53 to +58
const events = await PickEvent.findAll({
where: {
household_id: householdId,
occurred_at: { [Op.gte]: since },
},
});
Comment on lines +1 to +34
import { fetchWatchProviders } from './tmdb.service';

export interface ResolveLaunchInput {
tmdb_id: number;
media_type: 'movie' | 'tv';
provider_slug: string;
region: string;
}

export interface ResolvedLaunch {
url: string;
provider_name: string;
region: string;
}

const normalize = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]/g, '');

export class ProviderNotAvailableError extends Error {
constructor(slug: string, region: string) {
super(`Provider ${slug} not available in ${region}`);
this.name = 'ProviderNotAvailableError';
}
}

export const resolveProviderLaunch = async (
input: ResolveLaunchInput
): Promise<ResolvedLaunch> => {
const region = input.region.toUpperCase();
const wanted = normalize(input.provider_slug);

const all = await fetchWatchProviders(input.tmdb_id, input.media_type);
const regional = all[region];

Comment on lines +151 to +155
const region =
typeof body.region === 'string' && body.region ? body.region : 'GB';
if (!/^[A-Z]{2}$/.test(region)) {
return res.status(400).json({ error: 'invalid_region' });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants