import { CLOCK_DRIFT_THRESHOLD_S, clockTime, spokenAge, type TerminalStatus, } from './posStatus'; /** * What a counter has to say, in words a shopkeeper uses. * * This replaces a severity ladder — critical/warning/info over codes like * `queue-stranded` and `clock-drift`. That vocabulary is borrowed from * monitoring tools and means nothing to the person who actually opens this * screen: a supervisor who knows the shop and nothing about networks. "Stale" * is not a word about a cash register. * * So the model is now: a counter produces zero or more PROBLEMS, each of which * is one plain sentence, one bucket, and one instruction. One problem, one * card. A counter with two problems appears twice — which is the point, because * a card carrying two instructions carries neither. */ export type Bucket = 'now' | 'look' | 'fine'; export type ProblemCode = | 'money-stuck' | 'gone-quiet' | 'not-selling' | 'printer-down' | 'wrong-date' | 'storage-low' | 'battery-low' | 'drawer-open' | 'never-used' | 'no-status'; export interface Problem { code: ProblemCode; bucket: Bucket; /** The line in large type. Leads with what is at stake, not what is broken. */ headline: string; /** One sentence of what is actually true. */ detail: string; /** What a person should do. Rendered in bold; absent when there is nothing. */ action?: string; /** A reassurance, when the honest answer is "this looks worse than it is". */ comfort?: string; /** How long it has been true, in ms. null when we cannot know. */ forMs: number | null; } export const BUCKET_LABEL: Record = { now: 'Needs someone now', look: 'Worth a look', fine: 'All fine', }; export const BUCKET_RANK: Record = { fine: 0, look: 1, now: 2 }; /* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a rail down each card and a dot beside each heading. It is gone rather than moved. The console does not colour-code severity: `KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour ramp existed on this page and nowhere else in the product. Urgency is already carried twice over — by the group's name, in plain words, and by the order the groups appear in — so the colour was a third telling, in the loudest register available, on the one page a supervisor opens when they are already worried. */ export interface ProblemContext { /** Evaluation instant, so a whole board is judged against one clock. */ now: number; /** The branch's trading hours, if known — `"09:00"` / `"21:30"`. */ opentime?: string; closetime?: string; /** Counters confirmed stale on the PREVIOUS read. See the `gone-quiet` note. */ wasStale?: ReadonlySet; } /** * Is the branch shut right now? * * Anything unparseable answers "open", on purpose. This flag only ever * DOWNGRADES a problem, so failing to parse keeps the alert at full strength — * the safe direction to be wrong in. */ export function isClosed(context: ProblemContext): boolean { const open = parseClock(context.opentime); const close = parseClock(context.closetime); if (open === null || close === null) return false; const at = new Date(context.now); const mins = at.getHours() * 60 + at.getMinutes(); // A close time earlier than the open time means the shop trades past // midnight, so the open window wraps rather than being empty. return close < open ? mins >= close && mins < open : mins < open || mins >= close; } function parseClock(value: string | undefined): number | null { if (!value) return null; const match = /^(\d{1,2}):(\d{2})/.exec(value.trim()); if (!match) return null; const hours = Number(match[1]); const mins = Number(match[2]); if (!Number.isFinite(hours) || !Number.isFinite(mins)) return null; if (hours > 23 || mins > 59) return null; return hours * 60 + mins; } /** ₹ with Indian grouping and no decimals. Whole rupees are what people say. */ export function rupees(amount: number): string { return `₹${Math.round(amount).toLocaleString('en-IN')}`; } /** * Roughly what the unsent bills are worth. * * The health payload carries a COUNT of pending bills and no value — Fiesta * simply does not report one. But "41 sales haven't reached us" gives a * supervisor no sense of whether that is four thousand rupees or four hundred * thousand, and the difference decides whether they walk over now. * * So this estimates from the till's OWN average sale today, and every caller * must render it with the word "roughly" attached. It returns null rather than * guessing when the till has sold nothing today to average over — an estimate * from no data is a fabrication, not an estimate. */ export function estimatePendingValue(terminal: TerminalStatus): number | null { if (terminal.todayBills <= 0 || terminal.todayAmount <= 0) return null; if (terminal.pendingBills <= 0) return null; return (terminal.todayAmount / terminal.todayBills) * terminal.pendingBills; } const sales = (n: number) => `${n} sale${n === 1 ? '' : 's'}`; /** * Everything one counter has to say, worst first. */ export function problemsFor( terminal: TerminalStatus, context: ProblemContext, ): Problem[] { const problems: Problem[] = []; const closed = isClosed(context); const absent = terminal.presence !== 'online'; const neverSold = terminal.todayBills === 0 && terminal.lastBillAt === null; const pending = terminal.pendingBills; /** * Money on a counter nobody can see. * * The single worst thing this board reports, and the reason it exists. From * `models/poshealth.go`: "A shop quietly accumulating unsynced takings looks * completely normal from the shop floor, and this is the only thing that * makes it visible before someone reconciles a till and finds a day missing." * * It absorbs the presence problem — a card that said both "money stuck" and * "gone quiet" would be one card asking for the same walk to the same * counter, twice. */ if (absent && pending > 0) { const worth = estimatePendingValue(terminal); problems.push({ code: 'money-stuck', bucket: 'now', headline: `${sales(pending)} haven’t reached us`, detail: worth ? `Roughly ${rupees(worth)}, judging by this counter’s average sale today. The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.` : `The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`, action: 'Check the counter is switched on and its internet is working.', // The most important sentence on the page. It is the difference between // a supervisor phoning head office and a supervisor checking a router. comfort: 'The sales aren’t lost — the till keeps its own copy and sends them as soon as it reconnects.', forMs: terminal.oldestPendingMs || terminal.silentForMs, }); } /** * Dark, with nothing held. * * Urgent only when the shop is open and this counter has traded before. * Outside trading hours it is a shop that shut; on a counter that has never * sold it is almost certainly a spare, and `never-used` says so better. */ if (absent && pending === 0 && !neverSold) { const urgent = !closed; problems.push({ code: 'gone-quiet', bucket: urgent ? 'now' : 'look', headline: closed ? 'Switched off' : 'Gone quiet', detail: closed ? `We haven’t heard from this counter since ${clockTime(terminal.receivedAt)}. The shop is outside its opening hours, so this is probably just closing up.` : terminal.silentForMs === null ? 'We can’t see this counter at all, and the shop is open.' : `We haven’t heard from it for ${spokenAge(terminal.silentForMs)}, and the shop is open.`, ...(urgent ? { action: 'Check it’s switched on and connected. If it’s been turned off on purpose, you can ignore this.', } : {}), forMs: terminal.silentForMs, }); } /** * Reaching us, but nothing rung. * * `models/poshealth.go` again: "A till that is connected but has rung nothing * in three hours usually means a jammed printer or an absent cashier, and * neither shows up in a plain online/offline board." */ if (!absent && !closed && terminal.todayBills === 0 && terminal.lastBillAt !== null) { problems.push({ code: 'not-selling', bucket: 'look', headline: 'Hasn’t sold anything today', detail: `It’s switched on and reaching us, but nothing has been rung up. Its last sale was ${clockTime(terminal.lastBillAt)}.`, action: 'Usually a jammed printer, or nobody on the counter.', forMs: null, }); } if (terminal.printerReachable === false) { problems.push({ code: 'printer-down', bucket: 'look', headline: 'Printer isn’t responding', detail: 'Staff can still ring up sales on this counter, but it can’t print a receipt.', action: 'Check the printer is switched on, connected and has paper.', forMs: null, }); } if ( terminal.clockDriftSeconds !== null && Math.abs(terminal.clockDriftSeconds) > CLOCK_DRIFT_THRESHOLD_S ) { const drift = Math.abs(terminal.clockDriftSeconds) * 1000; problems.push({ code: 'wrong-date', bucket: 'look', headline: 'This counter’s clock is wrong', detail: `It’s set ${spokenAge(drift)} ${terminal.clockDriftSeconds > 0 ? 'ahead of' : 'behind'} the real time. Sales rung here can be recorded against the wrong day, which shows up later as a day that doesn’t add up.`, action: 'Set the device’s date and time to update automatically.', forMs: null, }); } if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) { problems.push({ code: 'storage-low', bucket: 'look', headline: 'Running out of space', detail: `Only ${terminal.storageFreeMb} MB left. If this counter goes offline it needs room to hold sales until it reconnects.`, action: 'Ask whoever set the device up to clear some space on it.', forMs: null, }); } if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) { problems.push({ code: 'battery-low', bucket: 'look', headline: `Battery at ${terminal.batteryLevel}%`, detail: 'It isn’t charging, so this counter will shut down on its own before long.', action: 'Plug it in.', forMs: null, }); } if (terminal.drawerStatus === 'open') { problems.push({ code: 'drawer-open', bucket: 'look', headline: 'Cash drawer is open', detail: 'This counter is reporting its cash drawer as open.', forMs: null, }); } /** * Never rung a sale. * * Said out loud rather than left silent. From the old console: "almost always * a commissioning probe or a till set up and never put into service — worth * saying, because 'offline' on such a terminal is not an incident." A board * that shows spares as permanent emergencies is a board people stop reading. */ if (neverSold) { problems.push({ code: 'never-used', bucket: 'look', headline: 'Never used', detail: 'This counter has never rung a sale. It’s most likely a spare, or one that was set up and never put into service.', action: 'If you don’t use it, hide it to keep this list tidy.', forMs: null, }); } return problems.sort((a, b) => BUCKET_RANK[b.bucket] - BUCKET_RANK[a.bucket]); } /** * The problem for a counter that sold but reports no status at all. * * These exist only in the sales figures — there is no presence record to build * a card from, so without this they are invisible on the board while sitting * plainly in the day's takings. */ export function noStatusProblem(bills: number, amount: number): Problem { return { code: 'no-status', bucket: 'look', headline: 'Not reporting its status', detail: `This counter sold ${rupees(amount)} across ${sales(bills)}, but it doesn’t tell us whether it’s switched on or how it’s doing.`, action: 'Usually an older version of the till app. Worth having it updated.', comfort: 'Those sales are safely recorded — this is only about status.', forMs: null, }; }