Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

View File

@@ -0,0 +1,318 @@
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<Bucket, string> = {
now: 'Needs someone now',
look: 'Worth a look',
fine: 'All fine',
};
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
export const BUCKET_COLOR: Record<Bucket, string> = {
now: 'var(--color-error, #d64545)',
look: 'var(--color-warning, #b7860b)',
fine: 'var(--color-success, #10b981)',
};
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<string>;
}
/**
* 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,
};
}