246 lines
8.5 KiB
TypeScript
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),
|
|
};
|
|
}
|