From 1b3a193243522ae221a51dcccd46b1deea4a144c Mon Sep 17 00:00:00 2001 From: TropicolX Date: Thu, 4 Jun 2026 16:40:22 +0100 Subject: [PATCH] Push custom values (signup date, subscription, stages) to Lobbyside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface host-pushed custom columns on the Lobbyside live-visitor table by passing three derived fields through `setVisitor`: - signed_up_date — user.createdAt (ISO calendar date) - subscription_type — lifetime > individual > team > vip > free - stages_completed — summed completedStageSlugs across course participations stages_completed needs course-participations on the current-user payload, so add it to the syncCurrentUser include. Derivation is extracted to a pure, unit-tested helper. Co-authored-by: Cursor --- app/components/lobbyside-widget/index.hbs | 11 ++- app/components/lobbyside-widget/index.ts | 67 ++++++++++++++++++- app/services/authenticator.ts | 2 + .../unit/components/lobbyside-widget-test.ts | 60 +++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/unit/components/lobbyside-widget-test.ts diff --git a/app/components/lobbyside-widget/index.hbs b/app/components/lobbyside-widget/index.hbs index d0ff4d2558..63a4772d58 100644 --- a/app/components/lobbyside-widget/index.hbs +++ b/app/components/lobbyside-widget/index.hbs @@ -16,7 +16,16 @@
{{/if}} \ No newline at end of file diff --git a/app/components/lobbyside-widget/index.ts b/app/components/lobbyside-widget/index.ts index c396c887a4..a33cfaab73 100644 --- a/app/components/lobbyside-widget/index.ts +++ b/app/components/lobbyside-widget/index.ts @@ -8,13 +8,73 @@ import type AuthenticatorService from 'codecrafters-frontend/services/authentica declare global { interface Window { Lobbyside?: { - setVisitor(visitor: { email: string; name: string; github: string }): void; + setVisitor(visitor: { + email: string; + name: string; + github: string; + stages_completed: string; + signed_up_date: string; + subscription_type: string; + }): void; }; } } export type LobbysideAudience = 'everyone' | 'staff'; +// Structural subset of UserModel so the derivation stays pure + unit-testable. +interface VisitorSource { + createdAt?: Date | null; + isVip?: boolean; + activeSubscription?: { isLifetimeMembership?: boolean } | null; + hasActiveSubscription?: boolean; + teamHasActiveSubscription?: boolean; + courseParticipations?: { completedStageSlugs?: string[] }[]; +} + +export interface LobbysideCustomFields { + stages_completed: string; + signed_up_date: string; + subscription_type: string; +} + +function subscriptionType(user: VisitorSource | null | undefined): string { + if (!user) { + return ''; + } + + if (user.activeSubscription?.isLifetimeMembership) { + return 'lifetime'; + } + + if (user.hasActiveSubscription) { + return 'individual'; + } + + if (user.teamHasActiveSubscription) { + return 'team'; + } + + if (user.isVip) { + return 'vip'; + } + + return 'free'; +} + +export function lobbysideCustomFields(user: VisitorSource | null | undefined): LobbysideCustomFields { + const stagesCompleted = (user?.courseParticipations ?? []).reduce( + (sum, participation) => sum + (participation.completedStageSlugs?.length ?? 0), + 0, + ); + + return { + stages_completed: String(stagesCompleted), + signed_up_date: user?.createdAt ? user.createdAt.toISOString().slice(0, 10) : '', + subscription_type: subscriptionType(user), + }; +} + export interface LobbysideWidgetSignature { Element: HTMLDivElement; Args: { @@ -26,6 +86,10 @@ export interface LobbysideWidgetSignature { export default class LobbysideWidgetComponent extends Component { @service declare authenticator: AuthenticatorService; + get customFields(): LobbysideCustomFields { + return lobbysideCustomFields(this.authenticator.currentUser); + } + get scriptId(): string { return `lobbyside-widget-${this.args.widgetId}`; } @@ -139,6 +203,7 @@ export default class LobbysideWidgetComponent extends Component = {}): UserModel { + return { + createdAt: new Date('2023-04-15T10:30:00.000Z'), + isVip: false, + activeSubscription: null, + hasActiveSubscription: false, + teamHasActiveSubscription: false, + courseParticipations: [], + ...overrides, + } as unknown as UserModel; + } + + test('returns empty fields for a missing user', function (assert) { + assert.deepEqual(lobbysideCustomFields(null), { + stages_completed: '0', + signed_up_date: '', + subscription_type: '', + }); + }); + + test('formats signed_up_date as an ISO calendar date', function (assert) { + const user = createUser({ createdAt: new Date('2023-04-15T10:30:00.000Z') }); + + assert.strictEqual(lobbysideCustomFields(user).signed_up_date, '2023-04-15'); + }); + + test('sums completed stage slugs across all course participations', function (assert) { + const user = createUser({ + courseParticipations: [{ completedStageSlugs: ['s1', 's2', 's3'] }, { completedStageSlugs: ['s1'] }, { completedStageSlugs: [] }], + } as unknown as Partial); + + assert.strictEqual(lobbysideCustomFields(user).stages_completed, '4'); + }); + + test('subscription_type prefers lifetime over every other signal', function (assert) { + const user = createUser({ + isVip: true, + hasActiveSubscription: true, + teamHasActiveSubscription: true, + activeSubscription: { isLifetimeMembership: true } as UserModel['activeSubscription'], + }); + + assert.strictEqual(lobbysideCustomFields(user).subscription_type, 'lifetime'); + }); + + test('subscription_type falls back through individual, team, vip, then free', function (assert) { + assert.strictEqual(lobbysideCustomFields(createUser({ hasActiveSubscription: true })).subscription_type, 'individual'); + + assert.strictEqual(lobbysideCustomFields(createUser({ teamHasActiveSubscription: true })).subscription_type, 'team'); + + assert.strictEqual(lobbysideCustomFields(createUser({ isVip: true })).subscription_type, 'vip'); + + assert.strictEqual(lobbysideCustomFields(createUser()).subscription_type, 'free'); + }); +});