ligth theme design fix

This commit is contained in:
2026-08-10 15:33:13 +05:30
parent 6cc3c9a0b7
commit 5ce580dced
34 changed files with 2399 additions and 87 deletions

View File

@@ -0,0 +1,491 @@
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]);
}