Files
loyaly-merchant/src/features/dashboard/mock/intelligence.mock.ts
2026-08-10 15:33:13 +05:30

492 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]);
}