import type {Insight} from '@/features/dashboard/types/dashboard'; import type { ActivityId, ActivityMetric, CampaignSummary, JourneyStage, } from '@/features/dashboard/types/intelligence'; import type {RangeKey} from '@/shared/types/api'; import {createRng} from '@/shared/mock/rng'; import {buildTimeseries} from './dashboard.mock'; /** * Activity fixtures, DERIVED rather than invented. * * Every number here is anchored to buildTimeseries() — the same generator the * Footfall and Revenue charts read. That is not tidiness: an activity layer * generated independently would let the dashboard claim 1,248 visitors in one * card and 3,400 visits in the card directly below it, and no merchant would * trust either number again. Activity counts are a share of real visitors, * journey stages are the real funnel, and campaign rows are slices of the * activity they run on. * * The chain is also enforced by construction: customers ≤ interactions, * repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can * produce more purchases than participants would make the funnel UI render * a widening cone, and the bug would look like a design problem. */ interface ActivitySeed { id: ActivityId; label: string; description: string; group: ActivityMetric['group']; accent: ActivityMetric['accent']; isFeatured: boolean; /** Interactions per visitor. Above 1 means a visitor does it more than once. */ perVisitor: number; /** Interactions per distinct customer — the repeat rate within the activity. */ intensity: number; /** Fraction of participants who come back afterwards. */ returnRate: number; /** Fraction of returning participants who then buy. */ buyRate: number; /** Whether taking part can grant a reward. */ hasRewards: boolean; /** LYTs granted per participating customer, on average. 0 where none. */ lytsPerCustomer: number; status: ActivityMetric['status']; } /** * The ecosystem, in reading order. * * Accents alternate deliberately and are fixed per activity: warm is what the * brand gives the customer, cool is what the system measures back. Two hues * across ten rows is what keeps this from becoming a colour-coded legend * nobody can hold in their head. * * `isFeatured` is what the dashboard shows. Six is the ceiling — the summary * exists to be glanced at, and ten small cards is a second dashboard. */ const CATALOG: ActivitySeed[] = [ { id: 'walk', label: 'Walk', description: 'Passers-by detected near the store', group: 'engagement', accent: 'warm', isFeatured: false, perVisitor: 1.9, intensity: 1.4, returnRate: 0.11, buyRate: 0.34, hasRewards: false, lytsPerCustomer: 0, status: 'live', }, { id: 'visit', label: 'Visit', description: 'Customers who checked in at the store', group: 'engagement', accent: 'warm', isFeatured: true, perVisitor: 1.0, intensity: 1.7, returnRate: 0.31, buyRate: 0.52, hasRewards: false, lytsPerCustomer: 12, status: 'live', }, { id: 'selfie', label: 'Selfie', description: 'In-store photos shared to the feed', group: 'engagement', accent: 'cool', isFeatured: true, perVisitor: 0.3, intensity: 1.8, returnRate: 0.32, buyRate: 0.47, hasRewards: true, lytsPerCustomer: 25, status: 'live', }, { id: 'spin', label: 'Spin', description: 'Reward wheel plays', group: 'engagement', accent: 'warm', isFeatured: true, perVisitor: 0.58, intensity: 1.76, returnRate: 0.23, buyRate: 0.43, hasRewards: true, lytsPerCustomer: 40, status: 'live', }, { id: 'scratch', label: 'Scratch', description: 'Scratch cards opened', group: 'engagement', accent: 'cool', isFeatured: false, perVisitor: 0.36, intensity: 1.5, returnRate: 0.19, buyRate: 0.38, hasRewards: true, lytsPerCustomer: 30, status: 'live', }, { id: 'challenge', label: 'Challenge', description: 'Multi-step tasks completed', group: 'engagement', accent: 'cool', isFeatured: true, perVisitor: 0.17, intensity: 1.2, returnRate: 0.48, buyRate: 0.55, hasRewards: true, lytsPerCustomer: 75, status: 'live', }, { id: 'friend', label: 'Referral', description: 'Friends invited by existing customers', group: 'growth', accent: 'warm', isFeatured: true, perVisitor: 0.07, intensity: 1.1, returnRate: 0.42, buyRate: 0.58, hasRewards: true, lytsPerCustomer: 120, status: 'live', }, { id: 'event', label: 'Event', description: 'Attendance at in-store events', group: 'growth', accent: 'cool', isFeatured: true, perVisitor: 0.11, intensity: 1.05, returnRate: 0.37, buyRate: 0.62, hasRewards: false, lytsPerCustomer: 50, status: 'paused', }, { id: 'brand', label: 'Brand campaign', description: 'Reach from partner and brand pushes', group: 'growth', accent: 'warm', isFeatured: false, perVisitor: 0.24, intensity: 1.3, returnRate: 0.16, buyRate: 0.29, hasRewards: true, lytsPerCustomer: 20, status: 'draft', }, { id: 'shop', label: 'Shop', description: 'Catalogue browsing that ended in a basket', group: 'commerce', accent: 'cool', isFeatured: false, perVisitor: 0.26, intensity: 1.15, returnRate: 0.35, buyRate: 0.71, hasRewards: false, lytsPerCustomer: 0, status: 'live', }, ]; function totalVisitors(range: RangeKey, storeId: string, endMs: number): number { return buildTimeseries(range, storeId, endMs).reduce( (a, p) => a + p.visitors, 0, ); } function totalPurchases(range: RangeKey, storeId: string, endMs: number): number { return buildTimeseries(range, storeId, endMs).reduce( (a, p) => a + p.purchases, 0, ); } /** * One activity's numbers for a scope. * * Every step is clamped to at least 1 below its parent once the parent is * non-trivial, so the funnel narrows even for a quiet store where rounding * would otherwise collapse two stages onto the same value and make a 100% * conversion appear out of nowhere. */ function buildMetric( seed: ActivitySeed, visitors: number, range: RangeKey, storeId: string, ): ActivityMetric { const rng = createRng('activity-metric', seed.id, storeId, range); const jitter = rng.float(0.88, 1.12); const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter)); const customers = Math.max(1, Math.round(count / seed.intensity)); const repeatVisits = Math.round(customers * seed.returnRate); const purchases = Math.round(repeatVisits * seed.buyRate); const attributedRevenueInr = Math.round(purchases * rng.float(310, 540)); return { id: seed.id, label: seed.label, description: seed.description, group: seed.group, accent: seed.accent, count, status: seed.status, // Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who // spins six times is not granted six rewards, and multiplying by `count` // would inflate the liability by the intensity factor on every activity. lytsIssued: Math.round(customers * seed.lytsPerCustomer), // Compared against the previous period of equal length, same convention as // the KPI deltas. Drawn rather than recomputed: the previous window's // activity split is not something the timeseries carries. deltaPct: Number(rng.float(-9, 24).toFixed(1)), impact: { customers, rewardClaims: seed.hasRewards ? Math.round(customers * rng.float(0.36, 0.62)) : undefined, repeatVisits, purchases, attributedRevenueInr, // Modelled, and says so. The whole chain above is derived from observed // footfall through fixed rates — no purchase here is joined to a // specific activity event, and the UI reads this field to disclose that. attribution: 'estimated', }, isFeatured: seed.isFeatured, }; } export function buildActivityMetrics( range: RangeKey, storeId: string, endMs: number, ): ActivityMetric[] { const visitors = totalVisitors(range, storeId, endMs); return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId)); } /** * The five-stage progression, built from the funnel that already exists. * * Visit and Purchase are NOT invented — they are the same visitor and purchase * totals the KPI row shows, so the first and third stage of the journey and * the first two KPI cards can never disagree. Engage and Return come from the * activity layer, Refer is the referral activity's own count. */ export function buildJourney( range: RangeKey, storeId: string, endMs: number, ): JourneyStage[] { const visitors = totalVisitors(range, storeId, endMs); const purchases = totalPurchases(range, storeId, endMs); const metrics = buildActivityMetrics(range, storeId, endMs); const engaged = metrics .filter((m) => m.group === 'engagement' && m.id !== 'walk') .reduce((a, m) => Math.max(a, m.impact.customers), 0); const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0; const returned = Math.round(purchases * 0.69); const raw: {id: JourneyStage['id']; label: string; value: number}[] = [ {id: 'visit', label: 'Visit', value: visitors}, // Engagement cannot exceed footfall, and on a quiet store the strongest // single activity can round above it. {id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)}, {id: 'purchase', label: 'Purchase', value: purchases}, {id: 'return', label: 'Return', value: returned}, {id: 'refer', label: 'Refer', value: Math.min(referrals, returned)}, ]; return raw.map((stage, i) => ({ ...stage, conversionPct: i === 0 || raw[i - 1].value === 0 ? undefined : Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)), })); } /** Campaign name and framing per activity. The numbers come from the activity. */ const CAMPAIGNS: { id: string; name: string; activityId: ActivityId; status: CampaignSummary['status']; /** Labels for the three funnel steps, in order. */ steps: [string, string, string]; }[] = [ { id: 'c-challenge', name: 'Weekend Challenge', activityId: 'challenge', status: 'live', steps: ['Participants', 'Repeat visits', 'Purchases'], }, { id: 'c-friend', name: 'Refer a Friend', activityId: 'friend', status: 'live', steps: ['Referrals', 'New customers', 'Purchases'], }, { id: 'c-event', name: 'Saturday Event', activityId: 'event', status: 'ended', steps: ['Attendees', 'Repeat visits', 'Purchases'], }, { id: 'c-spin', name: 'Spin & Win', activityId: 'spin', status: 'live', steps: ['Spins', 'Reward claims', 'Purchases'], }, ]; export function buildCampaigns( range: RangeKey, storeId: string, endMs: number, ): CampaignSummary[] { const metrics = buildActivityMetrics(range, storeId, endMs); return CAMPAIGNS.flatMap((c) => { const m = metrics.find((x) => x.id === c.activityId); if (!m) return []; // The middle step differs by campaign shape: a referral drive converts to // new customers, a spin converts to claimed rewards, everything else to a // return visit. Reading it off the impact chain rather than drawing a // fresh number is what keeps a campaign row consistent with the activity // card above it. const middle = c.activityId === 'friend' ? m.impact.customers : c.activityId === 'spin' ? (m.impact.rewardClaims ?? m.impact.repeatVisits) : m.impact.repeatVisits; return [ { id: c.id, name: c.name, activityId: m.id, accent: m.accent, status: c.status, steps: [ {label: c.steps[0], value: m.count}, {label: c.steps[1], value: middle}, {label: c.steps[2], value: m.impact.purchases}, ], attributedRevenueInr: m.impact.attributedRevenueInr, attribution: m.impact.attribution, }, ]; }); } const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100); /** * Insights, computed from the numbers actually on screen. * * Deliberately not a static list of sentences. Every claim below is derived * from the same fixtures the panels render, so an insight cannot contradict * the card next to it — which is the failure mode that makes merchants stop * reading an AI panel after the second week. * * Each one carries an action. An observation with no next step belongs in a * chart, not in a section called "What needs your attention". */ export function buildInsights( range: RangeKey, storeId: string, endMs: number, ): Insight[] { const metrics = buildActivityMetrics(range, storeId, endMs); const by = (id: ActivityId) => metrics.find((m) => m.id === id)!; const spin = by('spin'); const challenge = by('challenge'); const visit = by('visit'); const friend = by('friend'); const spinConversion = pct(spin.impact.purchases, spin.impact.customers); const challengeReturn = pct( challenge.impact.repeatVisits, challenge.impact.customers, ); const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers); const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn; const visitConversion = pct(visit.impact.purchases, visit.impact.customers); const insights: Insight[] = [ { id: 'i-spin', severity: spinConversion < 15 ? 'warning' : 'info', // The headline states the CONVERSION, not the direction of the count. // "Spin engagement is up 16%" reads as good news and is the wrong thing // to lead with — and it is also plainly wrong on a period where plays // fell, which is how a generated insight loses a merchant's trust. title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`, body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`, action: {label: 'Refresh reward catalogue', href: '/lyts'}, }, { id: 'i-challenge', severity: 'success', title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`, body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`, action: {label: 'Plan another challenge', href: '/activity'}, }, { id: 'i-visit', severity: visitConversion < 20 ? 'warning' : 'info', title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`, body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`, action: {label: 'Launch a visit offer', href: '/lyts'}, }, { id: 'i-friend', severity: 'info', title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`, body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`, action: {label: 'Promote the referral reward', href: '/lyts'}, }, ]; // Most severe first, so the panel's top row is always the thing that most // needs attention rather than whichever activity happens to be listed first. const rank = {error: 0, warning: 1, success: 2, info: 3} as const; return insights.sort((a, b) => rank[a.severity] - rank[b.severity]); }