diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 0676834f4..27012b921 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -41,6 +41,7 @@ async function withDayparts(shop) { const { notifyOrderAttention } = require('../helpers/order-attention'); const orderApproval = require('../utils/order-approval'); const billNumber = require('../utils/bill-number'); +const orderProgress = require('../utils/order-progress'); const spiceLevel = require('../utils/spice-level'); const readyBy = require('../utils/ready-by'); const { kitchenLoad, typicalRound } = require('../utils/kitchen-load'); @@ -66,6 +67,21 @@ const partnerVenues = require('../utils/partner-venues'); but a refusal with an empty message would tell a customer nothing. */ const ONLINE_ORDERING_DISABLED = 'Online ordering is not enabled for this branch.'; +/* + * The shop's own answering speed, remembered for a few minutes. + * + * Every phone watching an order asks for this on every poll, and the answer + * is a property of the SHOP that moves over days. Reading fifty sales per + * poll per phone to produce the same number would be the whole cost of this + * feature, for nothing. + */ +const ACCEPT_HISTORY_DAYS = 14; +const ACCEPT_HISTORY_ORDERS = 50; +const ACCEPT_ENOUGH_ORDERS = 5; +const ACCEPT_OUTLIER_MINUTES = 120; +const ACCEPT_CACHE_MS = 5 * 60 * 1000; +const ACCEPT_MINUTES_CACHE = new Map(); + const activeTenantFilter = () => ({ ...(BaseModel.license ? { license: BaseModel.license } : {}), ...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}), @@ -9989,6 +10005,16 @@ class SalesRepository { bill_ready: paymentStatus === 'Paid' && !cancelled, payment_status: paymentStatus, payment_mode: String(order.payment_mode || ''), + /* + * WHERE IT HAS GOT TO, as a trail of what has actually happened. + * + * Stage 5's whole point, and the reason it lives here rather than in + * the service: every door a customer's phone can reach this order + * through - the read, the history page's bulk read, a change, a + * cancellation - is drawn from this one shape, so none of them can + * describe the same order differently. See utils/order-progress. + */ + progress: orderProgress.progressOf(order), /* Asked for, and waiting on the shop. */ cancel_requested: order.cancel_requested === true, fulfilment: String(order.fulfilment || ''), @@ -10009,6 +10035,95 @@ class SalesRepository { }; } + /** + * HOW LONG THIS SHOP USUALLY TAKES TO ANSWER, from its own history. + * + * Stage 5 asks for "an ETA computed from the shop's own history", and this + * is the only ETA the data can honestly produce. There is no cooking time + * in here: nothing marks an order ready, so any minutes-until-food figure + * would be invented, and an invented ETA is worse than none - it is the + * number a customer waits against and then complains about. + * + * What the data does hold is how long orders sit in the approval queue + * before somebody works it, and that is the minute a waiting customer is + * actually anxious about: has anyone seen this at all. So the answer is + * about acceptance, and the page words it as a description of the past + * rather than a promise about this order. + * + * Null rather than a guess whenever the history is too thin, too old or + * unreadable. A shop with four orders behind it gets no figure. + */ + async typicalAcceptMinutes(branchId) { + const key = String(branchId || ''); + if (!key) return null; + + const cached = ACCEPT_MINUTES_CACHE.get(key); + if (cached && cached.until > Date.now()) return cached.minutes; + + let minutes = null; + try { + const db = await BaseModel.getDb(); + const branchObjectId = mongoose.Types.ObjectId.isValid(key) + ? new mongoose.Types.ObjectId(key) + : branchId; + /* + * BOUNDED BY _id, which every collection indexes. + * + * An ObjectId carries the second it was made, so `_id` above a + * fortnight ago is both a date range and an index walk - and the walk + * STOPS at the fortnight. Ranging on created_date instead would leave + * Mongo sorting a shop's entire sales collection in memory on a + * collection with no index for it, which on a busy shop is the kind of + * query that takes the rest of the process down with it. + * + * The minutes are still computed from created_date, which is the field + * that means what it says. + */ + const since = Date.now() - ACCEPT_HISTORY_DAYS * 86400000; + const rows = await db + .collection('sales') + .find( + { + branch_id: branchObjectId, + _id: { $gte: mongoose.Types.ObjectId.createFromTime(Math.floor(since / 1000)) }, + order_state: 'accepted', + order_state_by: { $nin: [null, ''] }, + order_state_at: { $ne: null }, + ...activeTenantFilter(), + }, + { projection: { created_date: 1, order_state_at: 1 } } + ) + .sort({ _id: -1 }) + .limit(ACCEPT_HISTORY_ORDERS) + .toArray(); + + const waits = rows + .map((row) => { + const from = new Date(row.created_date).getTime(); + const to = new Date(row.order_state_at).getTime(); + return Number.isFinite(from) && Number.isFinite(to) ? (to - from) / 60000 : NaN; + }) + /* An order accepted an hour later is a shop that had gone home, not a + shop that is slow. Leaving those in drags the middle of a busy + evening out to a number no customer would recognise. */ + .filter((wait) => Number.isFinite(wait) && wait >= 0 && wait <= ACCEPT_OUTLIER_MINUTES) + .sort((a, b) => a - b); + + if (waits.length >= ACCEPT_ENOUGH_ORDERS) { + const middle = waits[Math.floor(waits.length / 2)]; + minutes = Math.max(1, Math.ceil(middle)); + } + } catch (error) { + /* A figure nobody can read is simply not shown. It is decoration on a + status page, and a status page must not fail over decoration. */ + console.warn('[order-progress] could not read the accept history:', error && error.message); + minutes = null; + } + + ACCEPT_MINUTES_CACHE.set(key, { minutes, until: Date.now() + ACCEPT_CACHE_MS }); + return minutes; + } + /** One order of this branch's, by its id. Nothing wider: no list, no search. */ async findCustomerOrder({ branchId, orderId }) { if (!mongoose.Types.ObjectId.isValid(String(orderId || ''))) return null; diff --git a/api/src/services/customer-order.service.js b/api/src/services/customer-order.service.js index 9d2f5a0d6..c56e77dbe 100644 --- a/api/src/services/customer-order.service.js +++ b/api/src/services/customer-order.service.js @@ -150,6 +150,35 @@ async function read(body, context) { return { status: true, message: 'OK', data: await viewOf(order, context) }; } +/** + * How long this shop usually takes to answer, when that is what is being + * waited on. + * + * NOT A COOKING TIME, and there is deliberately no way to ask this for one. + * Nothing in the product marks an order ready, so minutes-until-food would be + * invented - and an invented ETA is worse than none, because it is the number + * the customer waits against and then comes to the counter about. + * + * What a held order's customer is actually anxious about is whether anybody + * has seen it, and the shop's own recent queue answers exactly that. Read + * only while the order is still waiting: a shop on automatic never holds one, + * and should never pay for the query. + */ +async function typicallyAcceptedIn(view, context) { + if (!view || !view.progress || view.progress.waiting_for !== 'acceptance') return {}; + const minutes = await minutesOrNull(context); + return minutes ? { typically_accepted_in_minutes: minutes } : {}; +} + +async function minutesOrNull(context) { + try { + return await salesRepository.typicalAcceptMinutes(context && context.branchId); + } catch (e) { + /* Decoration on a status page. It is never worth failing the read. */ + return null; + } +} + /* * THE WHOLE ANSWER, SO THE PHONE NEED NOT ASK TWICE. * @@ -171,8 +200,12 @@ async function read(body, context) { async function viewOf(order, context) { const seconds = await changeSeconds(context); const reason = whyNot(order, Date.now(), seconds); + const view = salesRepository.customerOrderView(order); return { - ...salesRepository.customerOrderView(order), + ...view, + /* How long this shop usually takes to answer, and ONLY while this order + is waiting to be answered. See typicallyAcceptedIn. */ + ...(await typicallyAcceptedIn(view, context)), /* Whether they may still move it, why not, and how long the shop leaves it open - so one read answers every question the page has, including what to count down. */ @@ -244,6 +277,9 @@ async function readMany(body, context) { /* The window is the shop's, not the order's, so it is read once. */ const seconds = await changeSeconds(context); const now = Date.now(); + /* And so is the answering speed. One read for the whole page, rather than + one per row - the reason this endpoint exists at all. */ + let acceptMinutes; const found = []; for (const one of asked) { const orderId = String((one && one.orderId) || '').trim(); @@ -255,8 +291,19 @@ async function readMany(body, context) { }); if (!order || String(order.token_id || '') !== token) continue; const reason = whyNot(order, now, seconds); + const view = salesRepository.customerOrderView(order); + if ( + view.progress && + view.progress.waiting_for === 'acceptance' && + acceptMinutes === undefined + ) { + acceptMinutes = await minutesOrNull(context); + } found.push({ - ...salesRepository.customerOrderView(order), + ...view, + ...(view.progress && view.progress.waiting_for === 'acceptance' && acceptMinutes + ? { typically_accepted_in_minutes: acceptMinutes } + : {}), can_change: reason === '', why_not: reason || undefined, change_seconds: seconds, diff --git a/api/src/utils/order-progress.js b/api/src/utils/order-progress.js new file mode 100644 index 000000000..3d79dcb06 --- /dev/null +++ b/api/src/utils/order-progress.js @@ -0,0 +1,158 @@ +'use strict'; + +/* + * WHERE AN ORDER HAS GOT TO, IN THE ONLY TERMS THAT ARE TRUE. + * + * Stage 5 of Intranet/docs/PRINT_APPROVAL_NOTIFICATION_ROADMAP.md - "the + * customer knows" - with the warning that stage carries printed on it: + * + * "Do not ship a stage nothing can move off. If `Ready` is unreachable + * because nobody presses anything, a frozen tracker is worse than none." + * + * So this is not a tracker. A tracker is a ladder with the future drawn on + * it, and the future is exactly the part we cannot promise: nothing in this + * product sets "ready", nobody presses "out for delivery", and a shop whose + * kitchen printer is off never reports a ticket at all. A ladder would draw + * three greyed rungs and stop on the first one, which tells a customer their + * order is stuck when it is being cooked. + * + * WHAT THIS IS INSTEAD: a TRAIL. Only what has actually happened, each entry + * with the moment it happened, newest last. A trail cannot freeze, because it + * never claims anything about what comes next. If the kitchen step never + * arrives the customer sees "Placed 7:42" and nothing missing - not a promise + * with a hole in it. + * + * The one thing it does name ahead of time is `waiting_for`, and only for the + * single case where the next move is guaranteed by the state machine rather + * than hoped for: an order held for approval MUST be accepted or refused by + * somebody, and order-approval.js is what makes that true. Nothing else is + * ever named before it happens. + * + * NO SENTENCES HERE, only keys and times. The ordering pages carry their own + * Tamil runtime with an English-keyed dictionary (assets/i18n.js); a sentence + * composed on the server arrives as English that no dictionary can reach. The + * page owns the words, this owns the facts, and neither can drift into the + * other's job. + * + * WHERE EACH FACT COMES FROM, and every one of them is written today by + * something other than this feature - which is the point. A progress view + * that needs new writes is a progress view that reports on itself: + * + * placed created_date, written when the order is inserted + * accepted order_state + order_state_at + order_state_by, written + * by the approval queue when a PERSON decides + * in the kitchen kitchen_printed_at, written when a till reports that a + * ticket actually came out of a printer + * refused order_state `rejected` + * cancelled sale_process `cancelled` / payment_status `Cancelled` + */ + +/** The only steps that exist. There is deliberately no `ready`. */ +const STEP = Object.freeze({ + PLACED: 'placed', + ACCEPTED: 'accepted', + IN_THE_KITCHEN: 'in_the_kitchen', + REFUSED: 'refused', + CANCELLED: 'cancelled', +}); + +/** Nothing moves off these, so the page can stop asking. */ +const SETTLED = Object.freeze([STEP.REFUSED, STEP.CANCELLED]); + +/** A date, or null - never an Invalid Date, which renders as the word. */ +function when(value) { + if (!value) return null; + const at = value instanceof Date ? value : new Date(value); + return Number.isFinite(at.getTime()) ? at : null; +} + +const text = (value) => String(value == null ? '' : value).trim(); + +/** + * Was this order accepted by a PERSON, or did it simply never stop? + * + * A shop on automatic never held the order and never decided anything, so + * saying "Accepted 7:42" alongside "Placed 7:42" describes a decision nobody + * made - two lines, one second apart, one of them fiction. `order_state_by` + * carries the name of whoever worked the queue and is empty on arrival, which + * is exactly the difference. + */ +function aPersonDecided(order) { + return text(order.order_state_by) !== ''; +} + +/** + * The trail this order has left, and where that leaves it now. + * + * @param {object} order the sale document, as stored + * @returns {{step: string, trail: Array<{step: string, at: Date|null}>, + * waiting_for: string, settled: boolean}|null} + */ +function progressOf(order) { + if (!order) return null; + + const process = text(order.sale_process).toLowerCase(); + const paymentStatus = text(order.payment_status); + const state = text(order.order_state).toLowerCase(); + + const cancelled = process === 'cancelled' || paymentStatus === 'Cancelled'; + const refused = state === 'rejected'; + + const trail = [{ step: STEP.PLACED, at: when(order.created_date) || when(order.date) }]; + + /* + * A DECISION, not a state. Only where somebody made one - and refusals are + * always somebody's, so they are always shown. + */ + if (state === 'accepted' && aPersonDecided(order)) { + trail.push({ step: STEP.ACCEPTED, at: when(order.order_state_at) }); + } + + /* + * Paper, in a kitchen. Not "the server sent it" and not "the queue holds + * it": a till reported that a printer produced it, which is the only form + * of this fact worth showing a customer. + */ + const kitchenAt = when(order.kitchen_printed_at); + if (kitchenAt || order.kitchen_printed === true) { + trail.push({ step: STEP.IN_THE_KITCHEN, at: kitchenAt }); + } + + /* + * An ending is the last line of the history, never an erasure of it. + * + * A customer who cancelled two minutes late wants to know the kitchen had + * already started - that is the difference between "fine" and "I should go + * and say something", and hiding the earlier trail hides it. + */ + if (refused) { + trail.push({ step: STEP.REFUSED, at: when(order.order_state_at) }); + } + if (cancelled) { + trail.push({ + step: STEP.CANCELLED, + at: when(order.customer_cancelled_at) || when(order.updated_date), + }); + } + + const step = trail[trail.length - 1].step; + + /* + * THE ONE THING NAMED BEFORE IT HAPPENS. + * + * A pending order has to be answered: order-approval.js allows pending to + * move only to accepted or rejected, and waiting-order-policy.js keeps + * asking until somebody does. So this is a guarantee of the state machine, + * not an expectation of a printer. Every other "next" is left unsaid. + */ + const waitingFor = !cancelled && !refused && state === 'pending' ? 'acceptance' : ''; + + return { + step, + trail, + waiting_for: waitingFor, + settled: SETTLED.includes(step), + }; +} + +module.exports = { progressOf, STEP, SETTLED }; diff --git a/api/tests/unit/services/customer-order.service.test.js b/api/tests/unit/services/customer-order.service.test.js index 58ecd398b..f4b47a28f 100644 --- a/api/tests/unit/services/customer-order.service.test.js +++ b/api/tests/unit/services/customer-order.service.test.js @@ -608,3 +608,138 @@ describe('after the order, by kind and in bulk', () => { expect(out).toEqual({ status: true, message: 'OK', data: { orders: [] } }); }); }); + +/* + * WHERE THE ORDER HAS GOT TO, and how long this shop usually takes to say. + * + * Stage 5 of the print roadmap. The trail itself is pinned in + * tests/unit/utils/order-progress.test.js; what matters here is that every + * door a customer's phone can reach an order through carries the same one, + * and that the shop's own answering speed is read only when somebody is + * actually waiting on it. + */ +describe('the customer is told where the order has got to', () => { + const held = () => + order({ + order_state: 'pending', + created_date: new Date(Date.now() - 60 * 1000), + }); + + afterEach(() => jest.restoreAllMocks()); + + test('EVERY DOOR CARRIES THE SAME TRAIL, so none of them can disagree', () => { + /* + * It is built in customerOrderView rather than in this service, which is + * what makes a read, a bulk read, a change and a cancellation describe one + * order one way. The alternative is four places computing it and a history + * page saying "With the kitchen" about an order the thank-you page calls + * accepted. + */ + const view = salesRepository.customerOrderView( + order({ kitchen_printed_at: new Date('2026-09-16T13:02:00.000Z') }) + ); + expect(view.progress.step).toBe('in_the_kitchen'); + expect(view.progress.trail.map((entry) => entry.step)).toEqual(['placed', 'in_the_kitchen']); + }); + + test('a held order is told how long this shop usually takes to answer', async () => { + shopAllows(30); + jest.spyOn(salesRepository, 'findCustomerOrder').mockResolvedValue(held()); + const asked = jest.spyOn(salesRepository, 'typicalAcceptMinutes').mockResolvedValue(4); + + const out = await customerOrder.read( + { orderId: ORDER_ID, token: '219' }, + { branchId: BRANCH, kind: 'restaurant' } + ); + expect(out.data.progress.waiting_for).toBe('acceptance'); + expect(out.data.typically_accepted_in_minutes).toBe(4); + expect(asked).toHaveBeenCalledWith(BRANCH); + }); + + test('A SHOP ON AUTOMATIC IS NEVER ASKED, because nobody is waiting on it', async () => { + /* The query reads fifty sales. Running it for every phone watching an + order that was never held would be the whole cost of this feature, + spent on a number nothing would draw. */ + shopAllows(30); + jest + .spyOn(salesRepository, 'findCustomerOrder') + .mockResolvedValue(order({ order_state: 'accepted' })); + const asked = jest.spyOn(salesRepository, 'typicalAcceptMinutes').mockResolvedValue(4); + + const out = await customerOrder.read( + { orderId: ORDER_ID, token: '219' }, + { branchId: BRANCH, kind: 'restaurant' } + ); + expect(asked).not.toHaveBeenCalled(); + expect(out.data.typically_accepted_in_minutes).toBeUndefined(); + }); + + test('a shop with too little history gets no figure rather than a guess', async () => { + shopAllows(30); + jest.spyOn(salesRepository, 'findCustomerOrder').mockResolvedValue(held()); + jest.spyOn(salesRepository, 'typicalAcceptMinutes').mockResolvedValue(null); + + const out = await customerOrder.read( + { orderId: ORDER_ID, token: '219' }, + { branchId: BRANCH, kind: 'restaurant' } + ); + expect(out.data.typically_accepted_in_minutes).toBeUndefined(); + expect(out.data.progress.waiting_for).toBe('acceptance'); + }); + + test('AND A HISTORY THAT CANNOT BE READ NEVER COSTS THE CUSTOMER THE PAGE', async () => { + /* It is a line under a trail. Failing the whole read over it would take + away the thing the customer actually opened the page for. */ + shopAllows(30); + jest.spyOn(salesRepository, 'findCustomerOrder').mockResolvedValue(held()); + jest + .spyOn(salesRepository, 'typicalAcceptMinutes') + .mockRejectedValue(new Error('database is gone')); + + const out = await customerOrder.read( + { orderId: ORDER_ID, token: '219' }, + { branchId: BRANCH, kind: 'restaurant' } + ); + expect(out.status).toBe(true); + expect(out.data.progress.step).toBe('placed'); + expect(out.data.typically_accepted_in_minutes).toBeUndefined(); + }); + + test('a page of held orders asks the shop its speed ONCE, not once a row', async () => { + /* + * The bug this endpoint exists to avoid, wearing a different hat: the + * figure is a property of the shop, so reading it per row would put + * twenty identical queries behind one page load. + */ + shopAllows(30); + const now = new Date(Date.now() - 60 * 1000); + const rows = {}; + const asked = []; + for (let i = 0; i < 5; i += 1) { + rows['o' + i] = { + _id: 'o' + i, + token_id: 't', + sale_process: 'KOT', + order_state: 'pending', + created_date: now, + items: [], + total: 0, + }; + asked.push({ orderId: 'o' + i, token: 't' }); + } + jest + .spyOn(salesRepository, 'findCustomerOrder') + .mockImplementation(async ({ orderId }) => rows[orderId] || null); + const speed = jest.spyOn(salesRepository, 'typicalAcceptMinutes').mockResolvedValue(3); + + const out = await customerOrder.readMany( + { orders: asked }, + { branchId: BRANCH, kind: 'restaurant' } + ); + expect(out.data.orders).toHaveLength(5); + expect(speed).toHaveBeenCalledTimes(1); + for (const row of out.data.orders) { + expect(row.typically_accepted_in_minutes).toBe(3); + } + }); +}); diff --git a/api/tests/unit/utils/order-progress.test.js b/api/tests/unit/utils/order-progress.test.js new file mode 100644 index 000000000..e02cb4767 --- /dev/null +++ b/api/tests/unit/utils/order-progress.test.js @@ -0,0 +1,348 @@ +'use strict'; + +/* + * A customer is told what happened, and never what might. + * + * Stage 5 of the print roadmap - "the customer knows" - carries its own + * warning: "Do not ship a stage nothing can move off. If `Ready` is + * unreachable because nobody presses anything, a frozen tracker is worse + * than none." + * + * Every test here is that sentence, checked from a different side. The + * temptation in this feature is a four-rung ladder - Placed, Accepted, + * Cooking, Ready - because that is what everybody has seen on a delivery + * app. Three of those four rungs would be drawn and never reached: + * + * Ready nothing in this product sets it. Nobody presses anything. + * Cooking we know a ticket PRINTED. Nothing tells us a cook started. + * Accepted a shop on automatic never decides anything, so the word + * describes a decision that was never made. + * + * A ladder would show a customer three grey rungs under a stuck dot while + * their dinner was being cooked. So there is no ladder. There is a trail of + * what has happened, and the only thing named ahead of time is the one move + * the state machine guarantees: a held order must be answered. + */ + +const { progressOf, STEP, SETTLED } = require('../../../src/utils/order-progress'); + +const AT = (iso) => new Date(iso); +const PLACED_AT = AT('2026-09-16T13:00:00.000Z'); + +/** An order as it exists the instant it is inserted. */ +const anOrder = (over = {}) => ({ + _id: '6aa5509215e3686c543e5cc3', + sale_process: 'KOT', + payment_status: 'Unpaid', + created_date: PLACED_AT, + ...over, +}); + +const steps = (order) => progressOf(order).trail.map((entry) => entry.step); + +/* ------------------------------------------------ the rung that is not there */ + +describe('there is no stage nothing can move off', () => { + test('READY DOES NOT EXIST, because nothing in the product sets it', () => { + /* + * The whole reason this module is a trail and not a tracker. If `ready` + * is ever added here, something must first exist that MOVES an order on + * to it - and at that point this test is the thing that has to change, + * deliberately, rather than a rung quietly appearing on ninety shops' + * customers' phones and never lighting up. + */ + expect(Object.values(STEP)).not.toContain('ready'); + expect(Object.values(STEP)).not.toContain('out_for_delivery'); + expect(Object.values(STEP)).not.toContain('collected'); + }); + + test('and the trail never contains a step that has not happened', () => { + /* A brand new order has exactly one true thing to say about itself. */ + expect(steps(anOrder({ order_state: 'pending' }))).toEqual([STEP.PLACED]); + expect(progressOf(anOrder({ order_state: 'pending' })).trail).toHaveLength(1); + }); + + test('the step an order is at is always the last thing that happened to it', () => { + /* Not a separate field that can drift from the history beside it. */ + for (const order of [ + anOrder({ order_state: 'pending' }), + anOrder({ order_state: 'accepted', order_state_by: 'Ravi', order_state_at: PLACED_AT }), + anOrder({ kitchen_printed_at: PLACED_AT }), + anOrder({ order_state: 'rejected', order_state_at: PLACED_AT }), + anOrder({ sale_process: 'cancelled' }), + ]) { + const progress = progressOf(order); + expect(progress.step).toBe(progress.trail[progress.trail.length - 1].step); + } + }); +}); + +/* --------------------------------------------------- a decision, not a state */ + +describe('accepted means a person decided', () => { + test('A SHOP ON AUTOMATIC NEVER SAYS "ACCEPTED", because it decided nothing', () => { + /* + * decideOnArrival writes order_state `accepted` on arrival for a shop that + * is not holding orders. Drawing that as a step would put two lines on the + * customer's screen one second apart - "Placed 7:42", "Accepted 7:42" - + * the second of which describes a decision nobody made. + * + * order_state_by is the difference: the approval queue writes the name of + * whoever worked it, and arrival writes nothing. + */ + const automatic = anOrder({ order_state: 'accepted', order_state_at: PLACED_AT }); + expect(steps(automatic)).toEqual([STEP.PLACED]); + }); + + test('and a shop that held it and answered does', () => { + const answered = anOrder({ + order_state: 'accepted', + order_state_at: AT('2026-09-16T13:04:00.000Z'), + order_state_by: 'Ravi', + }); + expect(steps(answered)).toEqual([STEP.PLACED, STEP.ACCEPTED]); + expect(progressOf(answered).trail[1].at).toEqual(AT('2026-09-16T13:04:00.000Z')); + }); + + test('a refusal is always somebody, so it is always shown', () => { + const refused = anOrder({ + order_state: 'rejected', + order_state_at: AT('2026-09-16T13:06:00.000Z'), + order_state_by: 'Ravi', + }); + expect(steps(refused)).toEqual([STEP.PLACED, STEP.REFUSED]); + /* And never alongside an "accepted" it passed through, because it did + not pass through one. */ + expect(steps(refused)).not.toContain(STEP.ACCEPTED); + }); +}); + +/* ------------------------------------------------------ paper, in a kitchen */ + +describe('"in the kitchen" means a printer produced paper', () => { + test('IT IS THE TILL REPORTING A PRINT, not the server sending an order', () => { + /* + * kitchen_printed_at is written by markKitchenPrintedModel, which runs + * when a till says it printed. Anything earlier - the order arriving, the + * queue holding a row - is us talking about our own intentions, and the + * customer cannot eat those. + */ + const printed = anOrder({ kitchen_printed_at: AT('2026-09-16T13:02:00.000Z') }); + expect(steps(printed)).toEqual([STEP.PLACED, STEP.IN_THE_KITCHEN]); + expect(progressOf(printed).trail[1].at).toEqual(AT('2026-09-16T13:02:00.000Z')); + }); + + test('an older row with the flag but no time still counts, without inventing one', () => { + const printed = anOrder({ kitchen_printed: true }); + expect(steps(printed)).toEqual([STEP.PLACED, STEP.IN_THE_KITCHEN]); + expect(progressOf(printed).trail[1].at).toBeNull(); + }); + + test('AND A SHOP WHOSE PRINTER NEVER REPORTS SIMPLY SHOWS LESS', () => { + /* + * The case the ladder gets wrong. A kitchen printer switched off, a till + * not running, a shop that prints nothing at all: the order is accepted + * and being cooked, and no ticket is ever reported. A ladder draws a grey + * rung and a stuck dot. A trail shows the two true lines and claims + * nothing - which is why a trail cannot freeze. + */ + const neverPrinted = anOrder({ + order_state: 'accepted', + order_state_by: 'Ravi', + order_state_at: AT('2026-09-16T13:01:00.000Z'), + }); + const progress = progressOf(neverPrinted); + expect(progress.step).toBe(STEP.ACCEPTED); + expect(progress.waiting_for).toBe(''); + expect(JSON.stringify(progress)).not.toContain('kitchen'); + }); +}); + +/* ------------------------------------------------------------- the endings */ + +describe('an ending is the last line of the history, not an erasure of it', () => { + test('A LATE CANCELLATION STILL SHOWS THAT THE KITCHEN HAD IT', () => { + /* + * The difference between "fine" and "I should go and say something". A + * customer who called the order off two minutes late needs to know the + * ticket had already printed; hiding the earlier trail hides exactly the + * thing they would act on. + */ + const cancelledLate = anOrder({ + sale_process: 'cancelled', + payment_status: 'Cancelled', + kitchen_printed_at: AT('2026-09-16T13:02:00.000Z'), + customer_cancelled_at: AT('2026-09-16T13:04:00.000Z'), + }); + expect(steps(cancelledLate)).toEqual([STEP.PLACED, STEP.IN_THE_KITCHEN, STEP.CANCELLED]); + expect(progressOf(cancelledLate).trail[2].at).toEqual(AT('2026-09-16T13:04:00.000Z')); + }); + + test('a cancellation the shop made carries the time it was touched', () => { + const cancelled = anOrder({ + sale_process: 'cancelled', + updated_date: AT('2026-09-16T13:09:00.000Z'), + }); + expect(progressOf(cancelled).trail[1].at).toEqual(AT('2026-09-16T13:09:00.000Z')); + }); + + test('payment_status alone is enough to be cancelled', () => { + expect(progressOf(anOrder({ payment_status: 'Cancelled' })).step).toBe(STEP.CANCELLED); + }); + + test('NOTHING MOVES OFF AN ENDING, so the phone is told to stop asking', () => { + expect(progressOf(anOrder({ sale_process: 'cancelled' })).settled).toBe(true); + expect(progressOf(anOrder({ order_state: 'rejected' })).settled).toBe(true); + /* And everything else can still move, so it must not be settled. */ + expect(progressOf(anOrder({ order_state: 'pending' })).settled).toBe(false); + expect(progressOf(anOrder({ kitchen_printed_at: PLACED_AT })).settled).toBe(false); + expect(SETTLED).toEqual([STEP.REFUSED, STEP.CANCELLED]); + }); +}); + +/* ------------------------------------------- the one thing named in advance */ + +describe('what is waited on', () => { + test('ONLY A HELD ORDER NAMES ITS NEXT MOVE, and only because it is guaranteed', () => { + /* + * order-approval.js allows pending to move to accepted or rejected and to + * nothing else, and waiting-order-policy.js keeps asking until somebody + * does one of them. That is a guarantee of the state machine. Every other + * "next" - a printer, a cook, a delivery - is a hope, and hopes are not + * put on a customer's screen. + */ + expect(progressOf(anOrder({ order_state: 'pending' })).waiting_for).toBe('acceptance'); + }); + + test('and nothing else does', () => { + for (const order of [ + anOrder({ order_state: 'accepted' }), + anOrder({ order_state: 'accepted', order_state_by: 'Ravi' }), + anOrder({ kitchen_printed_at: PLACED_AT }), + anOrder({ order_state: 'rejected' }), + anOrder({ sale_process: 'cancelled' }), + anOrder({}), + ]) { + expect(progressOf(order).waiting_for).toBe(''); + } + }); + + test('a cancelled order is not still waiting to be accepted', () => { + /* It can happen: a customer calls off an order the shop never opened. */ + const gone = anOrder({ order_state: 'pending', sale_process: 'cancelled' }); + expect(progressOf(gone).waiting_for).toBe(''); + expect(progressOf(gone).settled).toBe(true); + }); +}); + +/* ---------------------------------------------------------- a whole evening */ + +test('THE TRAIL ONLY EVER GROWS, through the life of one order', () => { + /* + * A customer watching this page sees it redraw every fifteen seconds. A + * line that appeared and then vanished would be worse than no line: it + * reads as the shop changing its mind about something that already + * happened. + */ + const life = [ + anOrder({ order_state: 'pending' }), + anOrder({ order_state: 'accepted', order_state_by: 'Ravi', order_state_at: PLACED_AT }), + anOrder({ + order_state: 'accepted', + order_state_by: 'Ravi', + order_state_at: PLACED_AT, + kitchen_printed_at: AT('2026-09-16T13:05:00.000Z'), + }), + ]; + + let before = []; + for (const moment of life) { + const now = steps(moment); + expect(now.slice(0, before.length)).toEqual(before); + expect(now.length).toBeGreaterThanOrEqual(before.length); + before = now; + } + expect(before).toEqual([STEP.PLACED, STEP.ACCEPTED, STEP.IN_THE_KITCHEN]); +}); + +/* ------------------------------------------------------------- rubbish in */ + +describe('nonsense never becomes a confident answer', () => { + test('no order, no progress', () => { + expect(progressOf(null)).toBeNull(); + expect(progressOf(undefined)).toBeNull(); + }); + + test('AN UNREADABLE DATE IS NO DATE, never the words "Invalid Date"', () => { + /* Which is what a customer would otherwise read on their own receipt. */ + for (const created_date of ['', 'soon', null, 0, {}, 'not a date']) { + const at = progressOf(anOrder({ created_date })).trail[0].at; + expect(at === null || Number.isFinite(at.getTime())).toBe(true); + } + }); + + test('a date stored as a string is still a date', () => { + const progress = progressOf(anOrder({ created_date: '2026-09-16T13:00:00.000Z' })); + expect(progress.trail[0].at).toEqual(PLACED_AT); + }); + + test('an order with nothing on it is still placed', () => { + const progress = progressOf({}); + expect(progress.step).toBe(STEP.PLACED); + expect(progress.trail[0].at).toBeNull(); + expect(progress.settled).toBe(false); + }); + + test('a state nobody recognises is not treated as a state', () => { + /* A newer build, a half-migrated row, a typo in a script. */ + const odd = anOrder({ order_state: 'ACCEPTED_BY_ROBOT', order_state_by: 'Ravi' }); + expect(steps(odd)).toEqual([STEP.PLACED]); + expect(progressOf(odd).waiting_for).toBe(''); + }); + + test('a name made only of spaces is not a person deciding', () => { + const blank = anOrder({ order_state: 'accepted', order_state_by: ' ' }); + expect(steps(blank)).toEqual([STEP.PLACED]); + }); +}); + +/* ----------------------------------------------- it says facts, not sentences */ + +test('NO SENTENCE IS COMPOSED HERE, because the page has a dictionary and this does not', () => { + /* + * /order carries its own Tamil runtime keyed by the English sentence + * (order/assets/i18n.js). A sentence built on the server arrives as English + * the dictionary cannot reach, so the customer gets a page in two languages + * - and nobody notices until a Tamil-reading customer is standing at a + * counter. Keys and times out; words on the page. + * + * Checked by ALLOW-LIST rather than by searching for prose, because a + * search for prose passes on anything it fails to think of - which is how + * an assertion ends up matching nothing at all. + */ + const allowed = new Set([...Object.values(STEP), 'acceptance', '']); + const progress = progressOf( + anOrder({ + order_state: 'accepted', + order_state_by: 'Ravi', + order_state_at: PLACED_AT, + kitchen_printed_at: PLACED_AT, + }) + ); + + const strings = []; + (function walk(value) { + if (typeof value === 'string') strings.push(value); + else if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === 'object' && !(value instanceof Date)) { + Object.values(value).forEach(walk); + } + })(progress); + + expect(strings.length).toBeGreaterThan(0); + for (const said of strings) { + expect(allowed.has(said)).toBe(true); + } + /* And nothing the shop typed: a staff name is not the customer's. */ + expect(JSON.stringify(progress)).not.toContain('Ravi'); +}); diff --git a/menu/i18n.js b/menu/i18n.js index d1c4ec0da..cda00bf17 100644 --- a/menu/i18n.js +++ b/menu/i18n.js @@ -304,6 +304,14 @@ "This receipt is not from an order placed on this phone.": "இந்த ரசீது இந்த போனில் செய்யப்பட்ட ஆர்டருடையது அல்ல.", "See the menu": "மெனுவைப் பாருங்கள்", + /* where the order has got to - the trail on the thank-you page */ + "The shop has it": "கடையிடம் உள்ளது", + "In the kitchen": "சமையலறையில்", + "Waiting for the shop to accept it.": "கடை ஏற்கும் வரை காத்திருக்கிறது.", + "Waiting for the shop to accept it. Most orders here are accepted in about {minutes} minutes.": "கடை ஏற்கும் வரை காத்திருக்கிறது. இங்கு பெரும்பாலான ஆர்டர்கள் சுமார் {minutes} நிமிடங்களில் ஏற்கப்படுகின்றன.", + "Nothing has been charged.": "எந்தத் தொகையும் வசூலிக்கப்படவில்லை.", + "Nothing has been charged. Ask at the counter if you would like to know why.": "எந்தத் தொகையும் வசூலிக்கப்படவில்லை. காரணம் தெரிய வேண்டுமானால் கவுண்டரில் கேளுங்கள்.", + /* the assistant */ "Ask about the menu": "மெனு பற்றிக் கேளுங்கள்", "New": "புதிது", diff --git a/order/assets/history/script.js b/order/assets/history/script.js index 0e72fc952..43d6b9072 100644 --- a/order/assets/history/script.js +++ b/order/assets/history/script.js @@ -29,11 +29,38 @@ return String((window.CONFIG && window.CONFIG.API_BASE_URL) || "").replace(/\/$/, ""); } - /* The words for a state, in the customer's terms rather than the - database's: nobody asks whether their dinner is "KOT". */ + /* + * The words for a state, in the customer's terms rather than the + * database's: nobody asks whether their dinner is "KOT". + * + * DRAWN FROM THE TRAIL the server sends, so this page and the thank-you + * page cannot describe the same order in two different ways. It used to + * say "With the kitchen" for anything the shop had accepted - including + * an order whose ticket had never printed, because the printer was off or + * the till was not running. That is exactly the claim Stage 5 exists to + * stop making: the kitchen has it when a till reports that a ticket came + * out of a printer, and not a moment before. + */ + const STEP_WORDS = { + placed: "The shop has it", + accepted: "The shop has it", + in_the_kitchen: "With the kitchen", + refused: "The shop could not take it", + cancelled: "Cancelled" + }; + function stateWords(row) { if (row.cancelled) return say("Cancelled"); if (row.paid) return say("Paid"); + const progress = row.progress; + if (progress && progress.step) { + if (progress.waiting_for === "acceptance") return say("Waiting for the shop"); + const word = STEP_WORDS[progress.step]; + if (word) return say(word); + } + /* A server older than the trail, or a row this phone remembered + before one existed. The old reading, which is never wrong about + pending or refused - only about the kitchen. */ if (row.state === "pending") return say("Waiting for the shop"); if (row.state === "rejected") return say("The shop could not take it"); return say("With the kitchen"); diff --git a/order/assets/i18n.js b/order/assets/i18n.js index d1c4ec0da..cda00bf17 100644 --- a/order/assets/i18n.js +++ b/order/assets/i18n.js @@ -304,6 +304,14 @@ "This receipt is not from an order placed on this phone.": "இந்த ரசீது இந்த போனில் செய்யப்பட்ட ஆர்டருடையது அல்ல.", "See the menu": "மெனுவைப் பாருங்கள்", + /* where the order has got to - the trail on the thank-you page */ + "The shop has it": "கடையிடம் உள்ளது", + "In the kitchen": "சமையலறையில்", + "Waiting for the shop to accept it.": "கடை ஏற்கும் வரை காத்திருக்கிறது.", + "Waiting for the shop to accept it. Most orders here are accepted in about {minutes} minutes.": "கடை ஏற்கும் வரை காத்திருக்கிறது. இங்கு பெரும்பாலான ஆர்டர்கள் சுமார் {minutes} நிமிடங்களில் ஏற்கப்படுகின்றன.", + "Nothing has been charged.": "எந்தத் தொகையும் வசூலிக்கப்படவில்லை.", + "Nothing has been charged. Ask at the counter if you would like to know why.": "எந்தத் தொகையும் வசூலிக்கப்படவில்லை. காரணம் தெரிய வேண்டுமானால் கவுண்டரில் கேளுங்கள்.", + /* the assistant */ "Ask about the menu": "மெனு பற்றிக் கேளுங்கள்", "New": "புதிது", diff --git a/order/assets/order.css b/order/assets/order.css index ff139bf7f..3978c9bc5 100644 --- a/order/assets/order.css +++ b/order/assets/order.css @@ -2345,6 +2345,94 @@ dialog.sheet:has(.sheet-gallery:not([hidden])) .sheet-handle { font-size: 13px; } +/* + * THE TRAIL: what has happened to this order, newest last. + * + * Quieter than the token, which is still the thing the customer carries to + * the counter. The dots are a history, not a progress bar - there is no + * unreached rung drawn ahead of the last one, because nothing here can + * promise the next step. The last dot is the darkest simply because it is + * where the order is now. + */ +.progress { + margin: 20px 0 0; + text-align: left; +} + +.progress-trail { + list-style: none; + margin: 0; + padding: 0; +} + +.progress-trail li { + position: relative; + display: flex; + align-items: baseline; + gap: 10px; + padding: 0 0 10px 22px; +} + +.progress-trail li:last-child { + padding-bottom: 0; +} + +.progress-trail li::before { + content: ""; + position: absolute; + left: 2px; + top: 6px; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--line-strong); +} + +.progress-trail li:last-child::before { + background: var(--ink); +} + +/* The thread between one moment and the next. Never drawn past the last + dot: the line would point at a step nobody has promised. */ +.progress-trail li:not(:last-child)::after { + content: ""; + position: absolute; + left: 6px; + top: 17px; + bottom: 2px; + width: 1px; + background: var(--line); +} + +.progress-what { + font-size: 14px; + font-weight: 600; + color: var(--ink-soft); +} + +.progress-trail li:last-child .progress-what { + color: var(--ink); +} + +.progress-at { + margin-left: auto; + font-size: 13px; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.progress-next { + margin: 10px 0 0 22px; + font-size: 13px; + color: var(--ink-soft); +} + +/* Refused or cancelled. The last dot stops meaning "here" and starts + meaning "this is where it ended". */ +.progress-ended .progress-trail li:last-child::before { + background: var(--danger); +} + .button-group { display: flex; flex-direction: column; diff --git a/order/assets/thankyou/script.js b/order/assets/thankyou/script.js index ee7a1266f..c92f24aa2 100644 --- a/order/assets/thankyou/script.js +++ b/order/assets/thankyou/script.js @@ -190,14 +190,15 @@ async function renderAndPrint() { void printedFlagKey; /* - * The bill appears when the shop says the money is in. + * And then the page watches the order. * - * Asked once as the page opens and again on the way back to it, because - * the till is where that changes and nothing tells this page when it - * does. A shop that has not been asked, or an order it has never heard - * of, simply leaves the button hidden. + * Where it has got to, and whether the shop has marked it paid - which is + * what puts a bill behind it. Asked as the page opens and again while + * somebody is looking at it, because the till is where those change and + * nothing tells this page when they do. A shop that cannot be reached, or + * an order it has never heard of, simply leaves the page as it is. */ - offerBillWhenPaid(token); + watchTheOrder(token); } @@ -274,51 +275,277 @@ function offerUpi(said, shopPayment, token, orderId) { box.hidden = false; } -/* Does the shop say this order is paid? If so, the bill is worth having. */ -async function offerBillWhenPaid(token) { +/* + * WATCHING ONE ORDER, from the phone that placed it. + * + * Stage 5 of the print roadmap - "the customer knows". The page used to ask + * the shop one question, once, as it opened: is this paid yet. Everything + * that happens to an order in the twenty minutes after that - a person + * accepting it, a ticket coming out of a kitchen printer, the shop refusing + * it at 2am - happened behind the customer's back, and the only way to find + * out was to walk to the counter and ask. + * + * WHAT IS DRAWN, and what is deliberately not. The server answers with a + * TRAIL: what has already happened, each with the moment it happened. There + * is no ladder of greyed-out future steps, because the steps after "in the + * kitchen" are not ours to promise - nothing marks an order ready, and a + * shop whose kitchen printer is off never reports a ticket at all. A ladder + * would draw three rungs and stop on the first, which reads as "stuck" to a + * customer whose food is being cooked. See api/src/utils/order-progress.js. + * + * HOW OFTEN IT ASKS. Insistently for the first two minutes, then slower, and + * NOTHING AT ALL while the phone is in a pocket: a screen nobody is looking + * at has nothing to redraw, and a full restaurant of phones polling from a + * table would be the entire cost of this feature. It asks once more the + * moment the customer looks again, which is exactly when the answer matters. + * + * It stops for good when nothing further can happen - refused, cancelled, or + * the money is in - and after half an hour regardless. + */ +const ASK_FAST_MS = 15000; +const ASK_SLOWER_MS = 30000; +const ASK_SLOW_MS = 60000; +const ASKS_BEFORE_SLOWER = 8; +const ASKS_BEFORE_SLOW = 16; +const MOST_ASKS = 40; + +/* The words for each step, in the customer's terms. The server sends keys + and times only, so that these can go through the dictionary like every + other sentence on the page - see assets/i18n.js. */ +const STEP_WORDS = { + placed: "Placed", + accepted: "The shop has it", + in_the_kitchen: "In the kitchen", + refused: "The shop could not take it", + cancelled: "Cancelled" +}; +const STEP_ENDED = ["refused", "cancelled"]; + +/* Read at most once each: the shop's payment details do not change while a + customer waits, and a button wired twice downloads twice. */ +let shopPaymentRead = null; +let billWired = false; + +function askPace(asks) { + if (asks < ASKS_BEFORE_SLOWER) return ASK_FAST_MS; + if (asks < ASKS_BEFORE_SLOW) return ASK_SLOWER_MS; + return ASK_SLOW_MS; +} + +/* + * Do this later, and only while somebody is looking. + * + * A phone locked in a pocket redraws nothing, so asking would spend the + * shop's request budget on an answer no one reads. The wait resumes the + * instant the screen comes back. + */ +function laterWhenLooking(fn, ms) { + setTimeout(function () { + if (!document.hidden) return fn(); + const onBack = function () { + if (document.hidden) return; + document.removeEventListener("visibilitychange", onBack); + fn(); + }; + document.addEventListener("visibilitychange", onBack); + }, ms); +} + +function clockOf(at) { + const date = at ? new Date(at) : null; + if (!date || isNaN(date.getTime())) return ""; + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +/* What has happened to this order, as a trail. Nothing about what has not. */ +function drawProgress(said) { + const box = document.getElementById("progress"); + const list = document.getElementById("progress-trail"); + const next = document.getElementById("progress-next"); + const progress = said && said.progress; + if (!box || !list || !progress || !Array.isArray(progress.trail) || !progress.trail.length) return; + + list.textContent = ""; + progress.trail.forEach(function (entry) { + const word = STEP_WORDS[entry && entry.step]; + /* A step from a newer server than this page: left out rather than + printed raw. Silence reads as nothing new; `in_the_oven` reads as + a bug. */ + if (!word) return; + const row = document.createElement("li"); + const what = document.createElement("span"); + what.className = "progress-what"; + what.textContent = t(word); + row.appendChild(what); + const at = clockOf(entry.at); + if (at) { + const when = document.createElement("span"); + when.className = "progress-at"; + when.textContent = at; + row.appendChild(when); + } + list.appendChild(row); + }); + if (!list.childElementCount) return; + + const ended = STEP_ENDED.indexOf(progress.step) !== -1; + box.classList.toggle("progress-ended", ended); + box.hidden = false; + + /* + * The only thing named before it happens, and only because the shop has + * to answer: an order held for approval moves to accepted or refused, + * and nothing else. The minutes are the shop's own recent history, worded + * as a description of the past rather than a promise about this order. + */ + if (next) { + if (progress.waiting_for === "acceptance") { + const minutes = Number(said.typically_accepted_in_minutes) || 0; + next.textContent = minutes + ? t("Waiting for the shop to accept it. Most orders here are accepted in about {minutes} minutes.", { minutes: minutes }) + : t("Waiting for the shop to accept it."); + next.hidden = false; + } else { + next.hidden = true; + } + } + + /* One line saying it twice is one line too many. */ + const placedLine = document.querySelector(".order-time"); + if (placedLine) placedLine.hidden = true; + + /* + * A refusal must not sit under a green tick and the words "Order placed". + * Nothing else is rewritten: what the page said about the table or the + * counter is still true. + */ + if (ended) { + const mark = document.querySelector(".done-mark"); + if (mark) mark.hidden = true; + const title = document.getElementById("done-title"); + if (title) title.textContent = t(STEP_WORDS[progress.step]); + const lead = document.getElementById("done-lead"); + if (lead) { + lead.textContent = progress.step === "refused" + ? t("Nothing has been charged. Ask at the counter if you would like to know why.") + : t("Nothing has been charged."); + } + const pay = document.getElementById("pay-upi"); + if (pay) pay.hidden = true; + const owed = document.getElementById("done-pay"); + if (owed) owed.hidden = true; + } +} + +/* Unpaid: offer to pay it. Paid: offer the bill. Never both. */ +async function offerMoneyOrBill(said, token, orderId, shopId) { const button = document.getElementById("done-bill"); - if (!button) return; - const kept = (typeof rememberedOrders === "function" ? rememberedOrders() : []).find( - (row) => row && String(row.token) === String(token) - ); - const orderId = new URLSearchParams(window.location.search).get("order") || (kept && kept.orderId) || ""; - const shopId = (kept && kept.shop) || (typeof knownBranchId === "function" ? await knownBranchId() : ""); - if (!orderId || !shopId) return; - try { - const response = await fetch( - `${CONFIG.API_BASE_URL}/online-ordering/${encodeURIComponent(shopId)}/orders/${encodeURIComponent(orderId)}?token=${encodeURIComponent(token)}`, - { method: "GET", headers: { Accept: "application/json" } } - ); - if (!response.ok) return; - const body = await response.json(); - if (!body || body.type !== "success" || !body.data) return; - /* Unpaid: offer to pay it. Paid: offer the bill. Never both. */ - if (!body.data.bill_ready) { - let payment = {}; + if (!button || !said) return; + if (said.progress && STEP_ENDED.indexOf(said.progress.step) !== -1) return; + + if (!said.bill_ready) { + if (shopPaymentRead === null) { try { - payment = (await getLatestShopPayment(shopId)) || {}; + shopPaymentRead = (await getLatestShopPayment(shopId)) || {}; } catch (e) { - payment = {}; + shopPaymentRead = {}; } - offerUpi(body.data, payment, token, orderId); - return; } - button.hidden = false; - button.addEventListener("click", async () => { - button.disabled = true; - try { - await generatePdfFromHtmlFile(); - } catch (error) { - console.error("Receipt PDF generation failed:", error); - alert(error.message || t("Receipt PDF could not be generated.")); - } finally { - button.disabled = false; - } - }); - } catch (error) { - /* Offline, or a shop that cannot be reached: no bill offered, which - is the same as before this existed. */ + offerUpi(said, shopPaymentRead, token, orderId); + return; + } + if (billWired) return; + billWired = true; + button.hidden = false; + button.addEventListener("click", async () => { + button.disabled = true; + try { + await generatePdfFromHtmlFile(); + } catch (error) { + console.error("Receipt PDF generation failed:", error); + alert(error.message || t("Receipt PDF could not be generated.")); + } finally { + button.disabled = false; + } + }); +} + +/* + * WHICH ORDER, AND AT WHICH SHOP - and why this is not the obvious two lines. + * + * It used to read `rememberedOrders()` and `knownBranchId()`, which live in + * indexedDB.js. THIS PAGE HAS NEVER LOADED indexedDB.js. Both calls were + * written behind `typeof ... === "function"` guards, so neither threw: the + * shop id came out empty, the function returned before its first request, and + * everything behind it - the bill when the shop marks the order paid, the + * offer to pay by UPI - has silently done nothing on this page since the day + * it was written. A guard that turns a missing dependency into a quiet + * nothing is how a shipped feature runs for months without ever running once. + * + * Loading indexedDB.js here is not the fix: it starts a timer that re-fetches + * the shop's whole menu every ten seconds, which is a lot to ask of a phone + * that is only showing a token. + * + * So this page answers from what it already holds: + * the order ?order= on the way in from the history page, or the sale id + * on the receipt this phone was handed at checkout + * the shop posnic_store, which indexedDB.js writes on every menu load, + * so it is there for anybody who reached this page by ordering + * + * STORE_ADDRESS_KEY in indexedDB.js is the same key; tests/online-ordering-ux + * pins the two spellings together, because a rename on one side would put + * this page back exactly where it was. + */ +const STORE_ADDRESS_KEY = "posnic_store"; + +function whichOrder() { + const params = new URLSearchParams(window.location.search); + const orderId = params.get("order") || String((receiptData && receiptData.sale_id) || ""); + let shopId = ""; + try { + shopId = localStorage.getItem(STORE_ADDRESS_KEY) || ""; + } catch (e) { + /* A browser that keeps nothing. The page still shows the token, which + is the thing the customer carries to the counter. */ } + return { orderId: String(orderId || "").trim(), shopId: String(shopId || "").trim() }; +} + +async function watchTheOrder(token) { + const { orderId, shopId } = whichOrder(); + if (!orderId || !shopId) return; + + let asks = 0; + const askOnce = async function () { + let said = null; + try { + const response = await fetch( + `${CONFIG.API_BASE_URL}/online-ordering/${encodeURIComponent(shopId)}/orders/${encodeURIComponent(orderId)}?token=${encodeURIComponent(token)}`, + { method: "GET", headers: { Accept: "application/json" } } + ); + if (response.ok) { + const body = await response.json(); + if (body && body.type === "success" && body.data) said = body.data; + } + } catch (error) { + /* Offline, or a shop that cannot be reached. The page keeps what + it is already showing and tries again, which is the whole + reason this is a loop rather than one question. */ + } + + if (said) { + drawProgress(said); + await offerMoneyOrBill(said, token, orderId, shopId); + /* Nothing further can happen to it, so nothing further is asked. */ + if (said.paid || (said.progress && said.progress.settled)) return; + } + + asks += 1; + if (asks >= MOST_ASKS) return; + laterWhenLooking(askOnce, askPace(asks)); + }; + + askOnce(); } async function generatePdfFromHtmlFile() { diff --git a/order/thankyou.html b/order/thankyou.html index 34bea4ad2..793c83ed8 100644 --- a/order/thankyou.html +++ b/order/thankyou.html @@ -57,6 +57,19 @@

Order placed

Placed --

+ + +