Foodops
All docs

Guides · Updated 2026-08-23 · v20c143f

KDS App (kds.foodops.io)

Audio narration for this article is generated with the weekly docs refresh.

Plain-English summary

The KDS is now its own application at kds.foodops.io — a kitchen mounts a tablet or TV, signs in once, picks the outlet, and gets a full-screen ticket board that updates in real time as cashiers ring orders. Kitchen staff tap items to move them Open → Preparing → Ready; finished and cancelled tickets file into their own tabs. It ships from the same codebase as the counter POS but as a separate build and deployment, so the kitchen screen no longer lives inside the counter app at /kitchen — that legacy surface is documented (and marked stale) in Kitchen Display System.

This entry covers the new dedicated app only. Kitchen setup concepts — configuring kitchens per outlet, category routing, kitchen printers, plan limits — are unchanged and live in Kitchen Display System; they are not repeated here.

When you'd use this

  • Mounting a dedicated kitchen screen (tablet/TV) that does nothing but show tickets
  • A multi-station kitchen (Hot Kitchen / Bar / Grill) where each station filters to its own tickets
  • A pass/expo TV in TV mode — display-only, no buttons, safe from stray taps
  • Answering "why isn't the order showing in the kitchen?" for orders rung on the counter POS

How it's deployed (why it's a separate app)

The KDS has its own entry point (kds.htmlsrc/kds/main.tsxKdsApp), its own Vite build (vite.kds.config.ts, output dist-kds), its own Docker image (Dockerfile.kds → ECR haafai/kds-app) and its own production workflow (kds-production.yml, push-to-main, single-brand foodops). It talks to the same backend hosts as the counter app (sales API + identity) [code: foodops.counter.app/.github/workflows/kds-production.yml, vite.kds.config.ts @ 1f77fc6]. The counter app's router has no /kitchen route anymore.

Getting the KDS — enable and set it up

"Can we add the KDS display?" — there is nothing to install and no separate KDS licence to buy. The KDS is a web app that ships with FoodOps at kds.foodops.io; getting it running is a matter of signing in and having a kitchen configured. On the kitchen tablet or TV:

  1. Open kds.foodops.io in the browser — there's no app-store download (it's a web app; add it to the home screen / launch it full-screen on the mounted device).
  2. Sign in with the same Hubits/FoodOps login you use for the counter. The KDS shares the SSO/identity — there is no special "KDS role" and no extra subscription for the app itself: its screens are gated only by being signed in, and the outlet list you see is simply the outlets your user already has access to. [code: foodops.counter.app/src/kds/KdsApp.tsx — /outlets and /display wrapped in <ProtectedRoute> with no requiredRoles; hubits-api-sales/Hubits.Sales.Api/Controllers/KitchenController.cs — class-level [SessionAuth], no role restriction @ a974d67/99e6847]
  3. Pick the outlet on the picker (one outlet at a time; it re-joins your last outlet on the next sign-in).

What actually decides whether tickets appear is kitchen configuration, done once in the admin app (admin.foodops.io), not on the KDS itself:

  • The outlet must be a restaurant outlet — retail outlets have no kitchens (kitchens are a restaurant-only concept). [code: foodops-admin-app/apps/admin/src/components/settings/restaurant/OutletDetailPanel.tsx — "Retail outlets have no kitchens…" @ 289bf0e]
  • Add at least one kitchen for the outlet: admin app → outlet settings → the outlet's Kitchens section → Add kitchen (a kitchen just needs a name, e.g. "Hot Kitchen", "Bar"). The number of kitchens per outlet is capped by your subscription plan — when you hit the cap the admin blocks Add kitchen and prompts to upgrade. [code: foodops-admin-app/apps/admin/src/components/settings/restaurant/sections/KitchensSection.tsx (kitchenLimitState → planLimits.maxKitchensAllowed); KitchenFormDialog.tsx (name-only form) @ 289bf0e]
  • Route menu items to that kitchen so their tickets show: in the admin menu, open the item → Item Preparation → "Select which kitchens prepare this item" and tick the kitchen(s) — this applies to simple (non-variant) items. For variant-type items (e.g. "Full Alfahm"), the product-level Item Preparation section is now hidden as a duplicate; kitchens are assigned per variant, in the variant grid, instead. Items not assigned to any kitchen never reach the KDS. [code: foodops-admin-app/apps/admin/src/components/menu/ItemFormPanel.tsx:1818-1839 "Item Preparation" kitchen checkboxes → item.kitchenIds @ 289bf0e]

Once a kitchen exists and items are routed to it, ring an order on the counter and it appears on the KDS board for that outlet + kitchen. No kitchen, or no items routed → the board stays empty — the single most common "why is the KDS blank" cause (see Prerequisites and Edge cases).

Can the Kitchen Display be shown on a TV? — yes, here's the setup

Yes. The KDS is a normal web page, so anything that can open a browser and show it on a screen works — a TV with a built-in browser, a small PC / mini-box / streaming stick plugged into the TV's HDMI, or a tablet mirrored to the TV. There is no separate "TV app" to install and no extra TV licence — it is the same kds.foodops.io app, just put on a bigger screen and switched into a display-only profile.

Set one up like this:

  1. Open kds.foodops.io in the browser on whatever drives the TV.
  2. Sign in with the normal Hubits/FoodOps login and pick the outlet (it re-joins that outlet automatically next time, so the screen comes back up on its own after a power cut). [code: foodops.counter.app/src/kds/pages/KdsOutletPickerPage.tsx — last-outlet auto-rejoin @ 1f77fc6]
  3. Pick the kitchen for that screen from the kitchen picker (top-right), if the outlet has more than one. Each TV can watch a different station — Hot Kitchen on one, Bar on another — and the choice is remembered per outlet on that device. [code: src/kds/pages/KdsDisplayPage.tsx — per-org persisted kitchen selection @ main]
  4. Turn on TV mode: the Settings (gear) dropdown, top-right → DisplayTV mode. Its own description in the app is "Hide action buttons for display-only screens" — it strips the per-item and per-ticket action buttons so a wall-mounted or passing-trade screen can't be knocked into the wrong state. The setting is remembered on that device (browser local storage, key kds.tvMode), so the TV stays in TV mode across reloads and restarts. [code: foodops.counter.app/src/components/kitchen/KitchenSettingsDropdown.tsx — <ToggleRow icon={<Tv/>} label="TV mode" description="Hide action buttons for display-only screens." />; src/kds/pages/KdsDisplayPage.tsx:75,327-331 — TV_MODE_KEY = 'kds.tvMode' persisted to localStorage @ main]
  5. Go fullscreen with the floating expand button pinned bottom-right of the board — fullscreen hides the header row so the whole panel is tickets. [code: src/kds/pages/KdsDisplayPage.tsx:751,827-844 @ main]
  6. Tune it for the viewing distance in the same Settings dropdown: Item font size (Small / Medium / Large), Layout (Masonry / Grid equal-height / Grid auto-height) and tickets per page — a TV people read from across the kitchen usually wants Large font and fewer tickets per page. [code: KitchenSettingsDropdown.tsx — Layout rows, Item font size sm/md/lg; KdsDisplayPage.tsx:333-363 persisted layout/font/page-size @ main]

The board pads itself into the screen's safe area (env(safe-area-inset-*)), so TV overscan shouldn't clip the header or footer. [code: src/kds/pages/KdsDisplayPage.tsx:821-824 @ main]

Two things worth knowing for an unattended TV:

  • A TV-mode screen is read-only. Nobody can bump or mark items ready on it — that's the point. Keep at least one tablet or non-TV-mode screen where staff actually advance tickets, or turn TV mode off on the screen the chefs touch.
  • Sound needs a first tap. Kitchen sounds only arm after a user gesture on the page (browsers block autoplay audio), so a TV that boots untouched will show tickets silently until somebody taps the screen once / uses the Test sound button. [code: src/kds/pages/KdsDisplayPage.tsx:572-598 — first-gesture sound arming @ main]

Walkthrough — from sign-in to working the board

1. Sign in

Same SSO/identity as the counter (/login, OIDC callback routes are built into the KDS app shell). [code: src/kds/KdsApp.tsx @ 1f77fc6]

2. Pick the outlet

You land on the outlet picker (/outlets): searchable cards (name, reference, city/country) with colour-stable initials avatars. After the first visit the app auto-rejoins the last outlet on login; choosing "Switch outlet" from the board suppresses that auto-bounce so you can re-pick. [code: src/kds/pages/KdsOutletPickerPage.tsx @ 1f77fc6]

3. The display board (/display)

Area What it does
Header row (left) Outlet name · Switch outlet · Sign out · Clear cache & sync (with confirmation dialog) · Test sound · theme toggle
Header row (right) Sort dropdown · Settings dropdown · kitchen picker (popover when the outlet has >1 kitchen; plain label when exactly 1; selection persists per outlet)
Status tabs Open / Preparing / Completed / Cancelled, each with a live count badge
All Day panel (left, collapsed by default) Aggregate per-product queue: "started/total" per item across all active tickets — the "how many shawarmas are in flight" view
Tickets grid Paginated ticket cards (page size 10/20/50/100), three layouts
Fullscreen button Floating bottom-right; fullscreen hides the header entirely

[code: src/kds/pages/KdsDisplayPage.tsx @ 1f77fc6]

4. Work a ticket

Each ticket card shows order number, order-type, table ("Table N") and covers for dine-in, customer, elapsed-time badge, items with quantities/modifiers/notes, allergy flags, and takeaway markers. Since 2026-06-22 each item row's primary label is the variant name (the specific sellable, e.g. "Full Alfahm", in preference to the generic parent product), alongside structured modifiers/flavors; a point-in-time modifier text snapshot is used as a fallback so flavors still show even if a master modifier was later renamed or deleted. [code: foodops.counter.app/src/components/kitchen/KitchenTicket.tsx — item name = item.variantName || item.productName, modifiersText fallback @ 2ec37203; hubits-api-sales/Hubits.Sales.Api/Controllers/KitchenController.cs GetKitchenTickets + Dto/Counter/Orders/KitchenTicketDto.cs ProductVariantName+ModifiersText @ 967fda53] Actions [code: src/components/kitchen/KitchenTicket.tsx @ 2ec37203]:

  • Per item: tap to advance — not-started → PreparingReady (compact per-item buttons; the whole row is tappable).
  • Per ticket: a footer button marks all remaining items at once — "start preparing" when nothing has started, "done"/mark-all-ready once everything is preparing.
  • Urgency colours: tickets escalate at 5 min (warn) → 10 min (urgent) → 15 min (critical, pulsing); done/cancelled tickets stay neutral.
  • Items marked Ready/Served render struck-through; fully-ready tickets leave the active rail and move directly to Completed — there is no longer a separate Ready tab to pass through. Cancelled orders land in Cancelled (with a toast at cancel time).

Changed since the May builds: the explicit "Bump" button (and recall chips) that briefly shipped on Ready tickets (5d1d387, 2026-05-11, noted in kitchen-display.md) is no longer present in KitchenTicket on current main — the flow is per-item/per-ticket status advancement plus the status tabs. [verified: no bump/recall code in KitchenTicket.tsx @ 1f77fc6]

5. Settings (gear dropdown)

  • All Day panel show/hide (hidden by default)
  • TV mode — display-only: hides footer and per-item action buttons (for pass-through TVs)
  • Layout — auto-height grid (default) / equal-height grid / masonry
  • Item font size — sm / md / lg
  • Tickets per page — 10 / 20 / 50 / 100

All preferences persist per device in localStorage (as does the selected kitchen, per outlet).

6. Real-time behaviour

  • SignalR push (org-scoped hub on the sales API): new orders, status updates, and cancellations trigger a refetch, debounced 500 ms so multi-item bursts collapse into one round trip.
  • Polling fallback: a 60-second timer tick now only refetches when warranted. While the SignalR hub is disconnected, every tick refetches (effectively still a 60 s cadence — the only freshness signal available). Once the hub is healthy, a tick refetches only if nothing has loaded in the last 3 minutes (SignalR pushes are the primary signal in that case). It pauses while the tab is hidden and does one immediate re-fetch when you switch back to the KDS. [code: foodops.counter.app/src/kds/pollPolicy.ts — HEALTHY_BACKSTOP_MS = 180_000, shouldRefetchOnTick(); src/kds/pages/KdsDisplayPage.tsx POLL_INTERVAL_MS = 60_000 is now the tick cadence, not the refetch cadence @ 7c23e6d]
  • Sounds: a new-order bell and an order-ready alert. Browser autoplay is "armed" on the first tap — if the screen was never touched after a reload, the first bell may be silent; the header's Test sound button confirms audio works.
  • The KDS intentionally connects only to the sales-API hub (the customer-ordering hub doesn't allow the kds.foodops.io origin).
  • Clear cache & sync drops the API/service-worker caches and re-pulls tickets — the recovery move for a stale display that doesn't lose the outlet/kitchen selection.

Prerequisites

Same as the legacy surface — at least one kitchen configured for the outlet with categories routed to it, and orders containing routed items; otherwise the board stays empty. See Kitchen Display System — Prerequisites (including plan limits on kitchens per outlet).

Key concepts

  • Outlet picker — the KDS is outlet-scoped; one screen shows one outlet at a time (auto-rejoins the last one).
  • Kitchen picker — within the outlet, filter to one station; tickets show only the items routed to that kitchen. Selection persists per outlet per device.
  • Status tabs — Open / Preparing / Ready / Completed / Cancelled, counts included. (The June board has five tabs — one more than the four noted in kitchen-display.md's 2026-06-03 changelog: Completed is its own tab again.)
  • All Day — aggregate item queue across tickets, with started/total per product.
  • TV mode — read-only display profile for screens nobody should touch.
  • Urgency escalation — 5/10/15-minute colour thresholds with a pulsing critical state.
  • Debounced refetch + polling fallback — SignalR-driven (500 ms debounce) with a 60 s safety poll (paused while the tab is hidden).

Common questions

Q: What's the difference between this and the old kitchen screen in the counter app? A: Same ticket data and backend; different shell. The KDS is now a standalone app at kds.foodops.io with an outlet picker, kitchen filter, TV mode, layout/font/page-size settings, and sounds. The counter app no longer has a /kitchen route.

Q: What do the five tabs mean? A: Open — items not started; Preparing — started; Ready — all items done, awaiting hand-off/close; Completed — closed/served orders; Cancelled — cancelled orders (each KOT item flagged, with a printed cancellation slip per kitchen if printers are configured).

Q: Does it auto-refresh? A: Yes — SignalR push (debounced 500 ms) plus a 60-second poll as fallback (the poll pauses while the tab is backgrounded and catches up the moment you return to it). No manual reload needed; "Clear cache & sync" exists for stale-display recovery.

Q: Why doesn't an order leave the KDS when I mark it ready? It only clears after we settle it. A: That's by design — the ticket hasn't vanished, it has moved to the Ready tab. When every item is marked ready the ticket leaves the Open/Preparing view you were working in (which is why it looks like it "disappeared"), and it clears off the board entirely — into the Completed tab — only when the order is closed/settled (paid, put on account, or otherwise closed) or fully served on the counter. The KDS mirrors the order's real lifecycle, so a plated order that's still waiting to be handed over and paid correctly stays on Ready. There is no "bump" or "clear from Ready" button on the current board — you clear a Ready ticket by closing/settling its order on the counter. [code: src/kds/pages/KdsDisplayPage.tsx — STATUS_TABS Open/Preparing/Ready/Completed/Cancelled (:42-48), server-side per-tab fetch (:392-422); src/components/kitchen/KitchenTicket.tsx — footer shows "Completed" only when order closed or all items served (:804) @ a974d67]

Q: During a rush the Ready tab fills up with orders we've already cooked — how do we work it? A: Switch to the Ready tab and treat it as your "plated / awaiting hand-off & payment" queue — its count is the backlog of cooked-but-not-yet-closed orders. As each one is handed over and closed/settled on the counter it drops off Ready (into Completed) on the next refresh. Filter by kitchen (bar/grill) so each station only sees its own Ready items, and for a wall screen sort Date (Newest) and raise Tickets per page in Settings so nothing hides on a second page. You don't have to babysit stale entries: an order that's already been paid/closed but that the backend still tags "ready" is dropped from the board automatically on the client side, so it won't linger on Ready. [code: src/kds/pages/KdsDisplayPage.tsx — isTerminalTicket() drops order-completed/closed/cancelled or all-items-served tickets from the active-work tabs (:61-72, applied :407-414) @ a974d67]

Q: How can we reload / refresh the KDS from our side? A: You rarely need to — the board refreshes itself over SignalR the instant the counter rings or advances an order (bursts are debounced ~½ second), backed by a 60-second safety poll and an automatic re-fetch whenever you switch back to the KDS tab. When a display genuinely looks stale, use the header's Clear cache & sync (the circular-arrow icon, with a confirm dialog) — it drops the cached data and re-pulls every ticket without losing your outlet/kitchen selection; that is the safe "reload from our side". Avoid a hard browser reload if you can: after a page reload the sound is muted until someone taps the screen once, so tap the screen (or press Test sound) to re-arm the bell. [code: src/kds/pages/KdsDisplayPage.tsx — POLL_INTERVAL_MS 60_000 (:41), visibility re-fetch (:429-457), handleClearCacheAndSync → clearAllCache (:462-475), armOnFirstGesture (:549) @ a974d67]

Q: Why didn't the new-order bell ring? A: Browser autoplay rules — sound unlocks on the first tap/keypress after load. Touch the screen once (or press Test sound) after mounting/rebooting the device.

Q: Can the Kitchen Display be shown on a TV screen? How is it set up? A: Yes. Open kds.foodops.io in a browser on whatever drives the TV (built-in TV browser, a mini-PC/stick on HDMI, or a mirrored tablet), sign in, pick the outlet and the kitchen for that screen, then turn on Settings (gear) → Display → TV mode and hit the fullscreen button at the bottom-right. Bump Item font size to Large for viewing distance. There's no separate TV app to install and no extra licence — it's the same web app on a bigger screen. See Can the Kitchen Display be shown on a TV? for the full walkthrough.

Q: Can I make it read-only for a hanging TV? A: Yes — Settings → TV mode hides all action buttons ("Hide action buttons for display-only screens"), and the choice is remembered on that device. Pair with the fullscreen toggle; the layout pads into the TV safe area so overscan doesn't clip the header/footer. Remember the chefs then need a non-TV-mode screen somewhere to actually advance tickets.

Q: Do I need a special device or a smart TV for the KDS? A: No. Anything that runs a modern browser and outputs to the screen works — the KDS is a web page, not an installed app. Nothing to buy from the app store, no KDS-specific licence, and no special "KDS user role": any signed-in user sees the outlets they already have access to.

Q: How do I show only the bar's drinks on the bar screen? A: Use the kitchen picker (top right) — pick "Bar". The selection sticks per outlet on that device. Items appear under whichever kitchen their category is routed to.

Q: A ticket has 12 items — do I tap each one? A: No — the ticket footer button marks all remaining items at once (start-preparing or mark-all-ready, depending on state). Per-item taps are for partial progress.

Q: Can one order appear on two kitchen screens? A: Yes — each station sees only the items routed to it; the order is Ready overall when every station finishes its items.

Q: What happens on the KDS when the cashier cancels an order? A: A toast ("Order cancelled") fires, the board refetches, and the ticket moves to the Cancelled tab.

Q: Does the KDS work offline? A: It's not offline-first like the counter POS. It caches API responses (and survives blips via the poll), but live ticket flow needs connectivity. There is an internal design doc for LAN-resilient counter↔KDS operation (docs/OFFLINE_COUNTER_KDS_LAN_OPTIONS.md in the repo) — options analysis, not shipped behaviour.

Q: Where's the date picker the old kitchen screen had? A: Gone — the dedicated board is a live view (today's active tickets per status), not a date-browsable history. Use counter/admin reports for history.

Q: I marked an item Ready by mistake — can I undo it from the KDS? A: There's no un-ready control on the current board (per-item buttons only advance: start → ready). Correct the order from the counter side if it matters; otherwise the ticket simply files as Ready/Completed.

Q: Can I open two outlets side by side? A: One outlet per browser session. Use a second device/browser profile for a second outlet.

Edge cases and known issues

Board empty although the counter shows orders

Same root cause as ever: no kitchen configured for the outlet, or categories not routed. See Kitchen Display System — Edge cases.

Wrong outlet after login

The app auto-rejoins the last-used outlet (lastOrganizationId in localStorage). Use Switch outlet — that path explicitly suppresses the auto-bounce.

Stale tickets after network hiccups

The 60 s poll usually self-heals. If not: Clear cache & sync (header refresh icon) — it drops cached data and re-pulls without losing your outlet/kitchen selection.

Silent bell on a freshly booted display

Autoplay arming — tap the screen once after boot; verify with Test sound.

Pager hides new tickets

With small page sizes, new tickets can land on another page (sort by Date Newest keeps them on page 1). For walls, raise tickets-per-page in Settings.

Honest gaps (verified 2026-06-11)

  • No live walk / screenshots of kds.foodops.io yet — everything above is verified against component source on main, not a browser session.
  • The Bump/recall flow from May 2026 is gone from current main; if a kitchen remembers it, that's why.
  • No date-browsing, no un-ready/undo control, no offline-first mode, no per-KDS feedback button (the counter's feedback button is not mounted in the KDS shell).

Related

  • Kitchen Display System — legacy surface, kitchen/category-routing setup, printers, plan limits (concepts shared with this app)

  • KOT — kitchen order tickets and printing

  • POS Takeaway Order — the counter side that feeds the board

  • Returns, Refunds & Voids — what cancellations look like kitchen-side

  • User Stories: POS — US-POS-012 (order appears on KDS), US-POS-013 (kitchen printer routing)

  • 2026-08-18 — codebase (hubits-api-sales 4f94ad6..98c202d, main; overlaps the already-recorded 4f94ad6..cdb3b32 range from 2026-08-09). The OrderKitchenController.cs TryCloseSettledOrderAsync/MarkMultipleServed hunk in this diff is the SAME auto-close-on-settlement change already logged on 2026-08-09 (order reaches terminal service status while already settled → auto-Closed, table freed, OrderClosed/OrderUpdated fan-out) — not new, no action. What IS new in this range: RestaurantKitchenController.cs's MarkPreparing and MarkReady had their BOM stock-deduction step reordered — previously deduction ran inline before dbContext.SaveChangesAsync() and a thrown exception returned a hard failure ('BOM stock deduction failed. Please try again.') that blocked the status change entirely; now the status-change save runs FIRST and BOM deduction runs afterward, isolated via a new BomDeductionRunner.DeductIsolatedAsync (its own identity map, cleared tracker), with the code comment stating any undeducted line is picked up idempotently at settle by PaymentProcessingService. Practical effect: marking an item Preparing or Ready on this controller's path can no longer be blocked by a stock-deduction failure. This is not documented anywhere in kitchen-kds.md (which doesn't discuss BOM/stock deduction), and doesn't contradict any current claim, so no edit is warranted. It also partially answers the open question from 2026-08-09 ('does the mark-ready path get an equivalent auto-close fix?') in the negative for this commit range: RestaurantKitchenController's MarkReady/MarkPreparing hunks here are BOM-deduction-only, with no TryCloseSettledOrderAsync-equivalent — that question stays open. Also unresolved and worth a follow-up: whether RestaurantKitchenController (vs OrderKitchenController, which the code comments explicitly tag as the KDS-facing path — 'sent by KDS') is actually in the dedicated kds.foodops.io call path at all, or belongs instead to the legacy /kitchen surface (kitchen-display.md) — the entry's frontmatter cites both controllers without disambiguating which serves which surface. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-10 — codebase (foodops.counter.app 447007a..7c23e6d main: 'perf(kds): stop polling the board while the SignalR hub is healthy' + related perf commits). Two proven behavior changes to the KDS board client, both contradicting documented claims in this entry. (1) The Ready tab is GONE: KdsDisplayPage.tsx's StatusTab type and STATUS_TABS array dropped 'Ready', leaving exactly four tabs (Open/Preparing/Completed/Cancelled). A new file src/kds/ticketDelta.ts ports the server's ComputeItemStatus/ComputeTicketStatus precedence client-side and explains why: a ticket only becomes eligible for 'ready' once every item is ready-or-cancelled, but that identical condition is what makes the server set the ticket's IsCompleted flag in the same transaction as the final mark-ready — so a 'ready' ticket was already 'completed' and the Ready tab could never actually render anything (readyCount, by contrast, counted any ticket with at least one ready item, so the badge lied). This directly contradicts the entry's most-repeated claim (Key concepts, two Common-questions Q&As, a UI-TODO) that a fully-prepared ticket sits on a distinct Ready tab until the order is settled/paid — corrected via edits above; the settlement/payment angle itself is NOT addressed by this diff (it's a client-side UI diff, no controller changes), so the edit flags rather than resolves that part pending a hubits-api-sales-side check. (2) Polling is now conditional: new src/kds/pollPolicy.ts (with pollPolicy.test.ts) makes the 60s timer just a tick — while the SignalR hub is Connected, a tick only refetches if nothing has loaded within HEALTHY_BACKSTOP_MS = 180_000 (3 min); only a disconnected hub keeps the old unconditional every-60s-refetch behavior. This softens the entry's blanket '60-second polling fallback' description — corrected in the Real-time behaviour section; the same 60s framing recurs in the auto-refresh Q&A, the reload/refresh Q&A, and the 'Stale tickets after network hiccups' edge case and should be reconciled with the 3-minute healthy-hub backstop on the next full pass. Unrelated in the same range, not actionable: ticket-age-label re-render isolation (TicketAgeLabel component, KitchenTicket.tsx) and card-level layout containment are pure perf/rendering work with no user-facing behavior change; a kitchenApiService.ts change hydrates the print-layer org timezone from IndexedDB for the standalone /print/kot/ tab — not cited by anything in this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-09 — codebase (hubits-api-sales 4f94ad6..cdb3b32, main: 'Implement auto-close functionality for settled orders in OrderKitchen, OrderPayment, and Orders controllers'). OrderKitchenController.MarkMultipleServed gained a new TryCloseSettledOrderAsync step: when an order that is already settled (Outstanding <= 0.01, or a credit sale) newly reaches its terminal service status inside this same call, the controller now sets SalesOrderStatus to Closed, frees a dine-in table, and fans out an OrderClosed/OrderUpdated envelope over SignalR — in the same transaction as the status-change save. The added code comment states the bug this closes: 'Pay-then-Picked leaves the order paid, picked and open forever' — i.e. previously, an order paid BEFORE its items were fully served/picked could reach 'settled + fully served' without its SalesOrderStatus ever transitioning to Closed. This is directly adjacent to two of this entry's Q&As: 'Why doesn't an order leave the KDS when I mark it ready? It only clears after we settle it' (which describes clearing to Completed as gated on closed/settled OR fully served) and 'During a rush the Ready tab fills up...' (which already documents isTerminalTicket() dropping paid/closed/served tickets from the client's active-work tabs). Practical effect worth a follow-up note: before this fix, a pay-first-then-serve order could end up in the gap the client-side isTerminalTicket() filter already hides from Open/Preparing/Ready, yet the server-side Completed bucket (driven by SalesOrderStatus, per the 2026-08-02 note on KitchenController's per-tab predicates) may never have picked it up either, since SalesOrderStatus stayed non-Closed — a ticket that vanishes from the active board without landing in Completed. This diff fixes that going forward for the mark-served path. Not yet confirmed: the commit subject names OrderKitchen, OrderPayment AND Orders controllers, but only the OrderKitchenController.cs hunk is visible in this diff, so whether the equivalent fix also covers the mark-ready path (RestaurantKitchenController/MarkMultipleReady, the entry's cited source for the Ready-tab claims) isn't proven by this diff alone — worth checking on the next pass before folding into the 'Ready tab' Q&A. Not actionable as an edit; no existing claim is contradicted, this closes a gap the entry didn't previously know to describe. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-02 — codebase (hubits-api-sales bb69312..ebe7330, main: perf/tenancy/cache range including 'perf(signalr): give order broadcasts a routing envelope so terminals stop refetching'). KitchenController.cs's per-tab bucket logic (Open/Preparing/Completed/Cancelled, used by both GetKitchenTickets and GetKitchenTicketCounts) was extracted into four shared static predicates with an explicit design comment: Open and Preparing OVERLAP by design — a ticket with mixed item progress (one item started, one not) satisfies both predicates and appears in the list/count for BOTH the Open tab and the Preparing tab at once; it only leaves Open once every live item has started, and only enters Preparing once at least one has. This isn't documented anywhere in this entry — the 'Status tabs' table and Key concepts read as if a ticket occupies exactly one tab at a time. Worth a short addition (Key concepts or a new Common-questions entry) explaining that a partially-started ticket is expected to show on both Open and Preparing, so kitchen staff aren't confused seeing an item they've already started still listed under Open. The stated purpose of the refactor is to stop badge/list drift between the two endpoints (previously hand-duplicated bucket logic could disagree) — this reinforces rather than contradicts the entry's existing 'During a rush the Ready tab fills up...' Q&A about counts matching the board. Also present again in this range (already covered by the 2026-08-01 note, not new): the multi-kitchen join-table filter fix in KitchenController/RestaurantKitchenController, and the SignalR routing-envelope refactor (OrderKitchenController/RestaurantKitchenController now publish via IOrderEventPublisher with salesOrderId/sessionId/scope) — both still backend-only, no foodops.counter.app diff yet showing the KDS client consuming the new fields to suppress refetches. Not actionable as an edit; no existing claim is contradicted. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-01 — codebase (hubits-api-sales bb69312..0d94f42, main: perf/tenancy/cache range including 'perf(signalr): give order broadcasts a routing envelope so terminals stop refetching'). Two backend changes intersect this entry's cited surface but do not contradict any documented claim. (1) Multi-kitchen ticket filter fix: GetKitchenTickets/GetKitchenTicketCounts (KitchenController.cs) and GetKitchenOrders/GetOrdersCounts (RestaurantKitchenController.cs) previously filtered strictly on the ticket's singular RestaurantKitchenId column; a code comment now explains a ticket routed to several kitchens only carries its FIRST kitchen in that column, so a second/third assigned kitchen's filtered query never returned it — a real prior bug. The fix ORs in a check against the KitchenOrderTicketKitchens join table. This firms up (does not change) the entry's existing 'Can one order appear on two kitchen screens?' Q&A — the documented per-station visibility now more reliably holds for tickets spanning more than one kitchen; worth folding into that Q&A on the next pass as a note that multi-kitchen visibility was recently hardened, but nothing currently written is false. (2) SignalR routing envelope: OrderKitchenController's mark-served/preparing/ready handlers and RestaurantKitchenController's MarkDone/MarkPreparing/MarkReady were refactored from ad-hoc Task.Run + hubContext broadcasts to a shared IOrderEventPublisher, and KitchenStatusUpdated/OrderReady/KitchenOrderUpdated/OrderUpdated payloads now carry salesOrderId/sessionId/scope so a receiving terminal can tell whether a broadcast is its own register session and skip an unneeded refetch. This is backend-only in this diff — no client (foodops.counter.app) change is included here to prove the KDS actually uses sessionId to suppress refetches yet, so the entry's documented 'SignalR push...debounced 500ms' + 60s poll behavior stands unchanged until a client-side diff shows otherwise. Not actionable as an edit; flagging for a future pass once/if the counter-app KDS client is seen consuming the new routing fields. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-20 — codebase (foodops-admin-app 289bf0e..3555578, fix(catalog): variant item create/edit). ItemFormPanel.tsx's product-level 'Item Preparation' kitchen-checkbox section (the entry's cited source for 'Route menu items to that kitchen', lines ~1818-1839) is now gated showKitchens && !isVariantFlow (previously showKitchens alone), with a new comment stating variant-type products assign kitchens per variant in the grid instead — removed as a 'duplicate' control per the commit subject. Practical effect: the entry's generic instruction ('open the item → Item Preparation → tick kitchens') still holds for simple, non-variant items, but no longer applies to variant-type items (the entry's own example, 'Full Alfahm', is a variant) — for those, kitchen routing must be done per-variant in the variant grid, a different UI location not yet documented anywhere in this entry or kitchen-display.md. Unrelated in the same commit range: a validation-error toast was added on ItemFormPanel submit failure, and a required 'Default Stock Location' field was added to the variant-flow inventory section — neither intersects a claim in this entry, not actionable here. Recommend a live-walk follow-up to locate and document the exact per-variant kitchen-assignment control in the grid. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-19 — codebase (foodops.counter.app 99c4a32..8faa421, feat: implement terminal ticket filtering for active-work tabs in KdsDisplayPage). New fix layered on top of the already-documented isTerminalTicket client-side drop: tabBadgeFor() now returns tickets.length (the already-filtered list) for whichever tab is currently active, instead of always trusting the server-provided counts.openCount/preparingCount/readyCount. Practical effect: previously the Ready tab's count badge could stay inflated by a stale/completed order the backend still tallied under readyCount even though that same ticket had already been dropped from the visible board by isTerminalTicket — the number and the ticket list could disagree. Now, for the open tab, the badge is derived from the same filtered list the board renders, so it can no longer show a phantom backlog. This directly firms up the existing 'During a rush the Ready tab fills up...its count is the backlog of cooked-but-not-yet-closed orders' Q&A, which described the ticket-list behavior but not this badge-count guarantee. Also confirmed (not a change): the filtering is explicitly scoped to ACTIVE_WORK_TABS (Open/Preparing/Ready) via a new ACTIVE_WORK_TABS set — Completed/Cancelled are intentionally left unfiltered since terminal tickets belong there, consistent with how the entry already describes those two tabs. A same-range commit ('fix(kds): match ticket order-type colors to counter OrderCard palette', KitchenTicket.tsx) swaps the dine-in/delivery ticket-header hues to match the counter's OrderCard palette — cosmetic only, no documented claim in this entry references order-type colors, so it isn't actionable here. Worth folding the badge-count detail into the 'During a rush' Q&A on the next verification pass; not urgent enough to force an edit now since no existing claim is contradicted. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-18 — codebase (hubits-api-sales 074c027..4c95164, fix(kitchen): prioritize live line-item notes over KOT snapshot notes). GetKitchenTickets now sources each kitchen ticket item's Note from the live SalesOrderLineItem.Note when present, falling back to the frozen KOT-time item.Note only if there is no live line item. Previously the Note shown on the KDS board was always the point-in-time KOT snapshot. Practical effect: a note added or edited on an order line after the KOT/ticket was generated (including per-item notes from the ordering app) now reaches the kitchen board on the next refresh, instead of being frozen at ticket-creation time. This is the mirror-image of the modifiersText snapshot-fallback already documented in this entry (modifiers still snapshot-fall-back; notes now prefer live). Not yet reflected in the entry's 'Work a ticket' section or Common questions — worth a short addition next verification pass. Unrelated in the same commit range: KitchenController's Open/Preparing/Completed status-filter SQL was refactored to derive bucket membership from item state (IsReady/IsServed) as a fallback alongside the KOT.IsCompleted flag, explicitly for self-healing stale flags (e.g. from the legacy /RestaurantKitchen/markReady path or races) — the underlying trigger that sets KitchenOrderTicket.IsCompleted=true when all items become ready is unchanged pre-existing logic (MarkMultipleReady), so this diff does not itself prove any change to the entry's 'Ready tickets stay on Ready until settled/served' claim; flagging only because the query-level change is adjacent to that claim and may warrant a closer read of KdsDisplayPage.tsx's per-tab fetch logic on the next pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-17 — codebase (Track G; foodops.counter.app@a974d67 main, hubits-api-sales@99e6847 main, foodops-admin-app@289bf0e main) — closing two real client questions. (1) KDS ready-not-clearing (client: "when food is marked ready in the KDS it's not going from the screen — it goes only after we settle it"): CONFIRMED the ready-tab claim still holds at HEAD — KdsDisplayPage.tsx STATUS_TABS are Open/Preparing/Ready/Completed/Cancelled (:42-48), each tab is a server-side status filter (loadTickets :392-422), and KitchenTicket.tsx footer reads "Completed" only when the order is closed or every item is served (:804); a fully-ready ticket therefore stays on the Ready tab until the order is closed/settled on the counter (designed — there is no bump/clear-from-Ready control). New supporting detail: isTerminalTicket() (:61-72, applied :407-414) drops paid/closed/served tickets from the active-work tabs client-side so a done order can't linger on Ready. Added three customer-phrased Q&As (ready-not-clearing, working the Ready tab in a rush, reload/refresh-from-our-side). (2) "Can we add the KDS display?": verified the KDS app has no separate licence or role gate/outlets + /display are <ProtectedRoute> with no requiredRoles and KitchenController is [SessionAuth] with no role restriction; the gating that matters is kitchen configuration in the admin app (kitchens per outlet are plan-limited via planLimits.maxKitchensAllowed; items are routed to kitchens via ItemFormPanel "Item Preparation" → item.kitchenIds; retail outlets have no kitchens). Authored a "Getting the KDS — enable and set it up" section. CORRECTED drift: the polling fallback is 60 s, not the 15 s the entry stated (POLL_INTERVAL_MS = 60_000, raised from 15 s because the unconditional 15 s poll was the largest steady-state API load) — fixed in the real-time bullet, the key concept, and the auto-refresh Q&A. Added foodops-admin-app to source_repos; frontmatter SHAs + last_verified bumped. No live UI walk (kds.foodops.io); the exact admin settings menu path is left as a UI-TODO.

  • 2026-06-29 — codebase (July-10 launch re-verify against new-stack prod HEADs: foodops.counter.app@32669a9 main, hubits-api-sales@19190b9 main; legacy haafai.app.foodops is fully retired at launch and this entry never cited it). UPHELD against current main: KDS is a standalone app (kds.html/vite.kds.config.ts/Dockerfile.kds/kds-production.yml); the five status tabs Open/Preparing/Ready/Completed/Cancelled (src/kds/pages/KdsDisplayPage.tsx:35-43STATUS_TABS defines exactly these five); item primary label = variant name (src/components/kitchen/KitchenTicket.tsx:674{item.variantName || item.productName}, with modifiersText fallback at :725-731); the May "Bump" button is still absent on current main (no bump/recall code in KitchenTicket.tsx); mark-ready back-channel (hubits-api-sales/OrderKitchenController.cs:484 MarkMultipleReady, mirrors IsReady/ReadyAt onto SalesOrderLineItem :520-521, KitchenStatusUpdated SignalR :181/320/451). No corrections needed; frontmatter SHAs + last_verified bumped.

Note: Seeded 2026-06-11 · codebase only (audit P1-10 coverage gap; split out of kitchen-display.md) Verified_by: claude-code · Last verified: 2026-06-11 Skip-check performed first: kitchen-display.md documents the legacy /kitchen/index surface with a 2026-06-03 changed-note flagging the kds.foodops.io split — its walkthrough remains legacy, so this dedicated-app entry was written (cross-linked, setup concepts not duplicated). Authored against foodops.counter.app@1f77fc6 (main) and hubits-api-sales@20fc7ca (main). Verified: standalone build/deploy chain (kds.html → vite.kds.config.ts → Dockerfile.kds → kds-production.yml → ECR haafai/kds-app, single-brand); app shell routes (/login, /outlets, /display); outlet picker with last-outlet auto-rejoin + switch-intent suppression; five status tabs Open/Preparing/Ready/Completed/Cancelled (corrects the 4-tab claim in kitchen-display.md's 2026-06-03 note); per-outlet persisted kitchen picker; All Day panel (collapsed default, started/total per product); settings (TV mode, masonry/equal/auto layouts, item font size, page size 10–100); pagination; fullscreen + TV safe-area padding; SignalR handlers (KitchenOrderAdded/OrderAdded/OrderStatusUpdated/OrderCancelled) with 500 ms debounce + 15 s polling fallback; kitchen sounds with first-gesture arming + test button; clear-cache-and-sync with confirmation; urgency thresholds 5/10/15 min; per-item and mark-all status actions. Also verified the May "Bump" button is removed on current main. No live UI walk — UI-TODOs mark every walk-dependent claim; no screenshots fabricated.

Additional verification events are appended here by the weekly KB sync and by manual re-verification passes. See _meta/failure-log.md for the global failure index.

Go beyond POS.
Choose the total solution.

Focus on creating the best experience for your guests while we handle the rest.