492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
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]);
|
||
}
|