Files
loyaly-merchant/src/features/dashboard/mock/briefing.mock.ts

246 lines
8.5 KiB
TypeScript

import type {DashboardBriefing, DashboardTask, Insight} from '@/features/dashboard/types/dashboard';
import type {RangeKey} from '@/shared/types/api';
import {buildTimeseries} from './dashboard.mock';
import {buildStoreComparison, buildRewardUsage} from './analytics.mock';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
import {storeName} from '@/features/stores/mock/stores.mock';
/**
* The narrative half of the dashboard.
*
* Every sentence here is DERIVED from the same generators the charts read —
* buildTimeseries, buildStoreComparison, buildRewardUsage, buildStaffSummary,
* buildRewards. Nothing is invented. That is the whole point: a summary that
* says footfall rose while the footfall chart above it falls is worse than no
* summary at all, and independent fixtures are how that happens. When these
* become a real model call, the model gets fed the same aggregates.
*
* It also means the copy moves when the scope does. Switch to one store and
* the summary talks about that store; switch the range and the percentages
* follow, because they are recomputed from the series rather than templated.
*/
const RUPEES = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
notation: 'compact',
});
const COUNT = new Intl.NumberFormat('en-IN', {
notation: 'compact',
maximumFractionDigits: 1,
});
const RANGE_WORD: Record<RangeKey, string> = {
'7d': 'the last 7 days',
'30d': 'the last 30 days',
'90d': 'the last 90 days',
mtd: 'the month so far',
ytd: 'the year so far',
// The generators receive a RangeKey, not the custom window's endpoints, so
// the narrative cannot name the dates yet. Reads correctly either way; give
// buildBriefing the start/end once the query carries them.
custom: 'the selected period',
};
/** Totals for a window, plus how it moved against the window before it. */
function windowTotals(range: RangeKey, storeId: string, endMs: number) {
const points = buildTimeseries(range, storeId, endMs);
const half = Math.floor(points.length / 2);
const sum = (xs: typeof points, k: 'visitors' | 'purchases' | 'revenue') =>
xs.reduce((a, p) => a + p[k], 0);
const recent = points.slice(half);
const prior = points.slice(0, half);
const visitors = sum(points, 'visitors');
const purchases = sum(points, 'purchases');
const revenue = sum(points, 'revenue');
// Compared half-to-half rather than against a separately generated previous
// period: the two halves come from one series, so the direction stated in
// prose is guaranteed to be the direction drawn on the chart.
const priorRevenue = sum(prior, 'revenue');
const revenueDeltaPct = priorRevenue
? ((sum(recent, 'revenue') - priorRevenue) / priorRevenue) * 100
: 0;
return {
visitors,
purchases,
revenue,
conversionPct: visitors ? (purchases / visitors) * 100 : 0,
revenueDeltaPct,
};
}
function buildAlerts(
storeId: string,
range: RangeKey,
endMs: number,
): Insight[] {
const alerts: Insight[] = [];
// 1. Rewards about to expire — the only thing on this dashboard with a
// hard deadline, so it outranks everything else.
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
if (expiring.length > 0) {
alerts.push({
id: 'alert-expiring',
severity: 'error',
title: `${expiring.length} reward${expiring.length > 1 ? 's' : ''} expiring within 72 hours`,
body: expiring.map((r) => r.name).join(', ') + '.',
action: {label: 'Review rewards', href: '/lyts'},
});
}
// 2. Staff absence, measured against the roster rather than a fixed count —
// two absent out of four is a different day from two out of twenty.
const staff = buildStaffSummary(storeId, range);
const missing = staff.absent + staff.onLeave;
if (staff.total > 0 && missing / staff.total >= 0.2) {
alerts.push({
id: 'alert-staffing',
severity: 'warning',
title: `${missing} of ${staff.total} staff are not on the floor`,
body: `${staff.absent} absent, ${staff.onLeave} on leave, ${staff.late} late. Cover may be short at peak.`,
action: {label: 'Open staff', href: '/staff'},
});
}
// 3. The weakest store, but only when there is a spread worth acting on.
if (storeId === 'all') {
const stores = buildStoreComparison(range, endMs);
if (stores.length > 1) {
const sorted = [...stores].sort((a, b) => a.revenueInr - b.revenueInr);
const worst = sorted[0];
const best = sorted[sorted.length - 1];
const gapPct = best.revenueInr
? ((best.revenueInr - worst.revenueInr) / best.revenueInr) * 100
: 0;
if (gapPct >= 25) {
alerts.push({
id: 'alert-store-gap',
severity: 'warning',
title: `${worst.name} is ${Math.round(gapPct)}% behind ${best.name}`,
body: `${RUPEES.format(worst.revenueInr)} against ${RUPEES.format(best.revenueInr)} over ${RANGE_WORD[range]}.`,
action: {label: 'Compare stores', href: '/stores'},
});
}
}
}
// 4. A reward people claim and never redeem is unspent liability sitting on
// the books — worth surfacing even though nothing is technically broken.
const usage = buildRewardUsage(storeId, range);
const dead = usage
.filter((r) => r.claimed >= 100 && r.usageRatePct < 30)
.sort((a, b) => a.usageRatePct - b.usageRatePct)[0];
if (dead) {
alerts.push({
id: 'alert-dead-reward',
severity: 'info',
title: `${dead.name} is claimed but not redeemed`,
body: `${COUNT.format(dead.claimed)} claims, ${Math.round(dead.usageRatePct)}% redeemed. The rest is outstanding liability.`,
action: {label: 'Open LYTs', href: '/lyts'},
});
}
return alerts;
}
function buildTasks(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardTask[] {
const staff = buildStaffSummary(storeId, range);
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
const tasks: DashboardTask[] = [
{
id: 'task-roster',
label: 'Confirm the evening roster',
detail:
staff.late > 0
? `${staff.late} arrived late today`
: 'Peak footfall starts at 18:30',
due: 'before 17:00',
isDone: false,
action: {label: 'Staff', href: '/staff'},
},
{
id: 'task-float',
label: 'Reconcile the till float',
due: 'end of day',
isDone: false,
},
];
if (expiring.length > 0) {
tasks.unshift({
id: 'task-expiring',
label: `Decide on ${expiring.length} expiring reward${expiring.length > 1 ? 's' : ''}`,
detail: 'Extend, or let them lapse and clear the liability',
due: 'today',
isDone: false,
action: {label: 'Review', href: '/lyts'},
});
}
// Deterministic and already ticked, so the list reads as a real day in
// progress rather than an empty form.
tasks.push({
id: 'task-open',
label: 'Open-of-day checks',
detail: storeId === 'all' ? 'All stores reported' : storeName(storeId),
due: 'morning',
isDone: true,
});
return tasks;
}
export function buildBriefing(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardBriefing {
const t = windowTotals(range, storeId, endMs);
const scope = storeId === 'all' ? 'across all stores' : `at ${storeName(storeId)}`;
const dir = t.revenueDeltaPct >= 0 ? 'up' : 'down';
const magnitude = Math.abs(Math.round(t.revenueDeltaPct));
const stores = storeId === 'all' ? buildStoreComparison(range, endMs) : [];
const leader = stores.length
? [...stores].sort((a, b) => b.revenueInr - a.revenueInr)[0]
: null;
const sentences = [
`Over ${RANGE_WORD[range]} ${scope}, ${COUNT.format(t.visitors)} visits turned into ${COUNT.format(t.purchases)} purchases — a ${t.conversionPct.toFixed(1)}% conversion rate — and ${RUPEES.format(t.revenue)} in revenue.`,
`Revenue in the back half of the period is ${dir} ${magnitude}% against the front half.`,
];
if (leader) {
sentences.push(
`${leader.name} is carrying the network at ${RUPEES.format(leader.revenueInr)}.`,
);
}
return {
summary: sentences.join(' '),
alerts: buildAlerts(storeId, range, endMs),
tasks: buildTasks(storeId, range, endMs),
};
}