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 = { '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), }; }