From 652f39135b948db1ceb824a87811a6432e01c93c Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 12:43:57 -0500 Subject: [PATCH 01/19] Build out Burger Barn (Hamburger Barn) menu + Toast-style category tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 needs a real restaurant's whole menu proving the engine. Built the burgerbarn builder template out from Hamburger Barn's (Arkadelphia, AR) actual public menu: 58 items across 13 categories — burgers, chicken sandwiches, Tex-Mex, plates, salads, soups & potatoes, appetizers, sides, shakes, desserts, kids, drinks — each routed to the station that makes it (grill / fry / shake bar / a new Salad & Prep line). Toast-style "make it a combo — fries & drink" upsell on every main; expanded shake flavors, salad dressings, fajita protein choice. Prices are sensible estimates the shop confirms in the builder. Engine: a generic Toast-style category-tab bar on the ordering screen (renderIntake). Tabs come from each item's category in first-appearance order, labels from an optional FLOW.catLabels map (else title-cased), and only appear when a menu is big enough (>8 items, >1 category) — the dry cleaner's short menu stays a plain grid. Search and the category filter share one applyFilter so they never fight. A 50-item menu is navigable instead of one flat wall of tiles. tests/burgerbarn.js: pins the full 58-item/13-category build, the routing (incl. salad->prep, chimi->fry, fajita->grill), the combo upsell + category labels, and the live preview rendering the category tabs. Verified live in-browser (58 items, 13 tabs, filtering, fan-out, zero console errors). Co-Authored-By: Claude Opus 4.8 --- JOURNAL.md | 26 +++++++++++++ builder.html | 90 ++++++++++++++++++++++++++++++++++++--------- pos.html | 22 ++++++++++- tests/burgerbarn.js | 21 +++++++++-- 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/JOURNAL.md b/JOURNAL.md index a72bfad..e4f4a22 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -1115,4 +1115,30 @@ order, every primitive, zero console errors. A whole plant, standing up from a c The point was never the cleaner. The point was that the cleaner was never special — and now there's a test that proves it. +--- + +## Day 2 — 2026-09-12 · The first real restaurant menu + +The restaurant engine already existed — stations, fan-out, a KDS, modifiers, 86 counts, tips, split checks. What +it didn't have yet was a *real restaurant's whole menu* proving it. Stage 2 is **Hamburger Barn** in Arkadelphia +(the same town as Ozark), and the point of a stage is that a real business in the vertical depends on the +software. So the `burgerbarn` builder template got built out from the shop's actual public menu: **58 items** +across 13 categories — the burgers (Bubba, Bacon Hickory, Chili, Mushroom Onion, Bacon Chipotle), the chicken +sandwiches, the Tex-Mex line, the salads and soups, the fried apps, shakes, desserts, kids — each routed to the +station that actually makes it (grill · fry · shake bar · a new **Salad & Prep** line), with the Toast-style +**"make it a combo — fries & drink"** upsell on every main. *(Prices in the template are sensible estimates; +the shop confirms its own in the builder — nobody knows a menu's prices like the people ringing it.)* + +Fifty-eight items is where a real lesson landed: a single flat wall of tiles is how you *lose* the rush. Toast +tabs its menu by category, so the engine now does too — a generic **category-tab bar** on the ordering screen, +driven straight off each item's `category` (labels from an optional `catLabels` map, else the category +title-cased), showing only when a menu is big enough to need it. The dry cleaner's short list stays a plain +grid; a fifty-item burger menu becomes Burgers / Chicken / Tex-Mex / Salads / Shakes / … at a tap. Search and +the category filter share one path so they never fight. It's a restaurant-shaped need that turned into an +engine primitive every vertical gets — the recurring shape of this whole project. + +`tests/burgerbarn.js` now pins all of it: the full 58-item, 13-category build; the routing (burger→grill, +rings→fry, shake→shakes, salad→prep, chimi→fry, fajita→grill); the combo upsell and category labels; and the +live preview actually rendering the category tabs and fanning one order out to the grill and the fry station. + *— to be continued —* diff --git a/builder.html b/builder.html index 07199ad..d0ef1f5 100644 --- a/builder.html +++ b/builder.html @@ -340,48 +340,104 @@ welcome:{ message:"Make someone's day today ✂️", specials:["Ask about the color special"] }, catalog:[ {id:"s1",name:"Haircut",price:35,category:"service",path:["chair"],performer:true}, {id:"s2",name:"Color",price:85,category:"service",path:["chair"],performer:true}, {id:"s3",name:"Product",price:22,category:"retail",path:[]} ], stations:[ {id:"desk",type:"central",label:"Front Desk",view:{money:true}}, {id:"chair",type:"production",label:"Chair",view:{money:false}}, {id:"book",type:"booking",label:"Appointments",view:{}}, {id:"clock",type:"timeclock",label:"Time Clock",view:{}}, {id:"sched",type:"schedule",label:"Schedule",view:{}}, {id:"office",type:"report",label:"Back Office",view:{money:true}} ] }, - // A REAL restaurant template — the Hamburger Barn menu (Arkadelphia, AR). Public menu; prices are - // sensible estimates the owner edits. One order fans out to the grill, the fry station, and the shake bar. - burgerbarn: { flowId:"burgerbarn", label:"Hamburger Barn (burger joint)", topology:"hub-and-spoke", blurb:"A real burger-joint menu — grill · fry · shakes", + // A REAL restaurant template — the full Hamburger Barn menu (Arkadelphia, AR), laid out Toast-style: the Front + // Counter menu tabs by category, and one order fans out to the grill, fry, shake bar, and salad/prep line. + // The public menu (hamburgerbarn.com) gives the items and sections; ⚠️ PRICES ARE SENSIBLE ESTIMATES the owner + // confirms in the builder — the shop knows its own prices. "Make it a combo" is the Toast-style fries+drink upsell. + burgerbarn: { flowId:"burgerbarn", label:"Hamburger Barn (burger joint)", topology:"hub-and-spoke", blurb:"The real Hamburger Barn menu — grill · fry · shakes · salad line", branding:{ name:"Hamburger Barn", brandColor:"#a3312a" }, endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[15,18,20]}, split:true} }, timer:{ mode:"aging", warnSec:360, badSec:720, label:"ticket" }, capacity:{ perOrderMins:6, max:15 }, kds:{warnSec:360,badSec:720}, + // Toast-style category tabs on the ordering screen (order = first appearance below) + catLabels:{ burger:"Burgers", chicken:"Chicken", sandwich:"Sandwiches", plate:"Plates", texmex:"Tex-Mex", app:"Appetizers", salad:"Salads", soup:"Soups & Potatoes", side:"Sides", shake:"Shakes", dessert:"Desserts", kids:"Kids", drink:"Drinks" }, catalog:[ - // ---- BURGERS → grill ---- + // ---- BURGERS → grill (all can "make it a combo") ---- {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"], modifiers:[{group:"Patty",required:true,options:[{name:"Single",price:0},{name:"Double",price:3}]}], - addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5}], + addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}], flags:["Allergy note"]}, - {id:"mushswiss",name:"Mushroom Swiss Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Bacon",price:1.5}]}, - {id:"gouda",name:"Gouda Burger",price:12.5,category:"burger",path:["grill"]}, - {id:"chipotle",name:"Chipotle Burger",price:12.5,category:"burger",path:["grill"]}, + {id:"bachick",name:"Bacon Hickory Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Extra bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"chiliburg",name:"Chili Burger",price:11.5,category:"burger",path:["grill"],addons:[{name:"Extra cheese",price:1},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"mushonion",name:"Mushroom Onion Burger",price:12,category:"burger",path:["grill"],addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"bacchip",name:"Bacon Chipotle Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Extra cheese",price:1},{name:"Make it a combo — fries & drink",price:3.5}]}, {id:"cheeseburger",name:"Cheeseburger",price:9,category:"burger",path:["grill"], - modifiers:[{group:"Cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}]}, + modifiers:[{group:"Cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}], + addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- CHICKEN SANDWICHES → grill (fried ones to fry); grilled/fried where it applies ---- + {id:"grillchick",name:"Grilled Chicken",price:10,category:"chicken",path:["grill"],addons:[{name:"Bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"cajunchick",name:"Cajun Chicken",price:10.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"honeymust",name:"Honey Mustard Chicken",price:10.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"chipjack",name:"Chipotle Jack Chicken",price:11,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"petitjean",name:"Petit Jean Mountain Chicken",price:11.5,category:"chicken",path:["grill"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"cfc",name:"Chicken Fried Chicken",price:11,category:"chicken",path:["fry"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- OTHER SANDWICHES ---- + {id:"dagwood",name:"Dagwood",price:11,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"hamcheese",name:"Ham & Cheese",price:9,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"angus",name:"Angus Steak Sandwich",price:13,category:"sandwich",path:["grill"],addons:[{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}]}, + {id:"turkey",name:"Turkey Bistro",price:10,category:"sandwich",path:["prep"],addons:[{name:"Make it a combo — fries & drink",price:3.5}]}, + // ---- PLATES ---- {id:"cfs",name:"Chicken Fried Steak",price:13,category:"plate",path:["grill"]}, - {id:"grillchick",name:"Grilled Chicken Sandwich",price:10,category:"sandwich",path:["grill"]}, - // ---- FRIED APPS & PLATES → fry ---- + {id:"catfish",name:"3-pc Catfish Plate",price:13,category:"plate",path:["fry"]}, + {id:"special",name:"Blue Plate Special",price:10,category:"plate",path:["grill"],par:10}, // daily special, 86 count + // ---- TEX-MEX ---- + {id:"fajitas",name:"Flaming Fajitas",price:15,category:"texmex",path:["grill"], + modifiers:[{group:"Protein",required:true,options:[{name:"Chicken",price:0},{name:"Steak",price:2},{name:"Combo",price:2}]}]}, + {id:"quesadilla",name:"Mesquite Chicken Quesadilla",price:11,category:"texmex",path:["grill"]}, + {id:"tenders",name:"Chicken Tenders",price:10,category:"texmex",path:["fry"]}, + {id:"chimi",name:"Chicken Chimichanga",price:11,category:"texmex",path:["fry"]}, + {id:"tamales",name:"Hot Tamales",price:8,category:"texmex",path:["grill"]}, + {id:"enchilada",name:"Chicken Enchilada",price:10,category:"texmex",path:["grill"]}, + {id:"nachos",name:"Macho Nachos",price:10,category:"texmex",path:["prep"]}, + {id:"fritopie",name:"Frito Chili Pie",price:8,category:"texmex",path:["prep"]}, + {id:"bigdog",name:"The Big Dog (chili cheese dog)",price:7,category:"texmex",path:["grill"]}, + {id:"chili",name:"Soon-To-Be-Famous Chili",price:6,category:"texmex",path:["prep"]}, + // ---- APPETIZERS → fry (fried) / prep (cold) ---- {id:"onionrings",name:"Onion Rings",price:6,category:"app",path:["fry"]}, {id:"friedpickles",name:"Fried Pickles",price:6,category:"app",path:["fry"]}, {id:"greenbeans",name:"Fried Green Beans",price:6,category:"app",path:["fry"]}, {id:"cheesesticks",name:"Big Cheese Sticks",price:7,category:"app",path:["fry"]}, {id:"friedmush",name:"Fried Mushrooms",price:6,category:"app",path:["fry"]}, {id:"trio",name:"Big Teaser Trio",price:9,category:"app",path:["fry"]}, - {id:"catfish",name:"3-pc Catfish Plate",price:13,category:"plate",path:["fry"]}, + {id:"appcombo",name:"Appetizer Combo",price:10,category:"app",path:["fry"]}, + {id:"queso",name:"Queso with Chips & Salsa",price:7,category:"app",path:["prep"]}, + {id:"guac",name:"Guac with Chips & Salsa",price:7,category:"app",path:["prep"]}, + {id:"chipsalsa",name:"Chips & Salsa",price:4,category:"app",path:["prep"]}, + // ---- SALADS → salad/prep line (dressing choice, add chicken) ---- + {id:"housesalad",name:"House Salad",price:6,category:"salad",path:["prep"], + modifiers:[{group:"Dressing",required:true,options:[{name:"Ranch",price:0},{name:"Petit Jean Ranch",price:0},{name:"Honey Mustard",price:0},{name:"Italian",price:0},{name:"Bleu Cheese",price:0}]}], + addons:[{name:"Add grilled chicken",price:3}]}, + {id:"clubsalad",name:"Club Salad",price:10,category:"salad",path:["prep"]}, + {id:"mesquitesalad",name:"Mesquite Chicken Salad",price:11,category:"salad",path:["prep"]}, + {id:"ranchsalad",name:"Petit Jean Ranch Salad",price:10,category:"salad",path:["prep"]}, + // ---- SOUPS & POTATOES → prep ---- + {id:"broccoli",name:"Broccoli Cheese Soup",price:5,category:"soup",path:["prep"]}, + {id:"bisque",name:"Tomato Bisque",price:5,category:"soup",path:["prep"]}, + {id:"soupsalad",name:"Soup & Salad Combo",price:9,category:"soup",path:["prep"]}, + {id:"bakedpotato",name:"Loaded Baked Potato",price:6,category:"soup",path:["prep"]}, + // ---- SIDES → fry ---- {id:"fries",name:"Fries",price:3,category:"side",path:["fry"]}, // ---- SHAKES → shake bar ---- {id:"shake",name:"Milkshake",price:5,category:"shake",path:["shakes"], - modifiers:[{group:"Flavor",required:true,options:[{name:"Vanilla",price:0},{name:"Chocolate",price:0},{name:"Strawberry",price:0}]}]}, - // ---- DRINKS → handed over at the counter (no path) ---- + modifiers:[{group:"Flavor",required:true,options:[{name:"Vanilla",price:0},{name:"Chocolate",price:0},{name:"Strawberry",price:0},{name:"Cookies & Cream",price:0.5},{name:"Caramel",price:0.5}]}]}, + // ---- DESSERTS → prep ---- + {id:"breadpudding",name:"Apple Bread Pudding",price:6,category:"dessert",path:["prep"]}, + {id:"moltencake",name:"Chocolate Turtle Molten Cake",price:7,category:"dessert",path:["prep"]}, + // ---- KIDS ---- + {id:"babybubba",name:"Baby Bubba",price:6,category:"kids",path:["grill"]}, + {id:"chickenbites",name:"Chicken Bites",price:6,category:"kids",path:["fry"]}, + {id:"cornpups",name:"Corn Pups",price:6,category:"kids",path:["fry"]}, + {id:"macncheese",name:"Mac & Cheese",price:5,category:"kids",path:["prep"]}, + {id:"grilledcheese",name:"Grilled Cheese",price:5,category:"kids",path:["grill"]}, + // ---- DRINKS → handed over at the counter (no prep) ---- {id:"drink",name:"Fountain Drink",price:2.5,category:"drink",path:[]}, - {id:"tea",name:"Sweet Tea",price:2.5,category:"drink",path:[]}, - // ---- daily special with an 86 count ---- - {id:"special",name:"Blue Plate Special",price:10,category:"plate",path:["grill"],par:10} + {id:"tea",name:"Sweet Tea",price:2.5,category:"drink",path:[]} ], stations:[ {id:"counter",type:"central",label:"Front Counter",view:{money:true}}, {id:"grill",type:"production",label:"Grill",view:{money:false}}, {id:"fry",type:"production",label:"Fry Station",view:{money:false}}, {id:"shakes",type:"production",label:"Shake Bar",view:{money:false}}, + {id:"prep",type:"production",label:"Salad & Prep",view:{money:false}}, {id:"kds",type:"kds",label:"Kitchen Display",view:{money:false}}, {id:"office",type:"report",label:"Office",view:{money:true}} ] }, diff --git a/pos.html b/pos.html index abfe93f..5ed9b9e 100644 --- a/pos.html +++ b/pos.html @@ -1890,15 +1890,34 @@ const tiles=[]; const addItem=item=>{ const it=priceItem(item); if(!it) return; return needsConfig(it) ? (pending={item:it, choices:{}, addons:[], flags:[], performer:null}, render()) : addLine(it, {}, [], [], null); }; + // 🍔 Toast-style category tabs: a long menu (a real restaurant) is unusable as one flat wall of tiles, so we + // tab it by category. Generic to every vertical — categories come from the catalog in first-appearance order, + // labels from an optional FLOW.catLabels map (else the category title-cased). The dry cleaner's short menu + // stays a plain grid. Search + category filter combine through one applyFilter so they never fight. + let activeCat = draft.__cat || "__all"; + const catLabel = c => (FLOW.catLabels && FLOW.catLabels[c]) || (c ? c.charAt(0).toUpperCase()+c.slice(1) : "Other"); + const applyFilter=()=>{ const s=document.getElementById("catSearch"); const q=s?s.value.trim().toLowerCase():""; + tiles.forEach(({item,btn})=>{ const inCat=(activeCat==="__all")||((item.category||"other")===activeCat); + const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase(); + btn.style.display=(inCat && (!q||hay.includes(q)))?"":"none"; }); }; if(FLOW.catalog.length>5 || FLOW.endpoints.scan){ const search=document.createElement("input"); search.id="catSearch"; search.placeholder="Search or scan…"; search.autocomplete="off"; search.style.cssText="width:100%;margin-bottom:10px"; - search.oninput=()=>{ const q=search.value.trim().toLowerCase(); tiles.forEach(({item,btn})=>{ const hay=(item.name+" "+(item.category||"")+" "+(item.barcode||item.sku||"")).toLowerCase(); btn.style.display=(!q||hay.includes(q))?"":"none"; }); }; + search.oninput=applyFilter; search.onkeydown=e=>{ if(e.key!=="Enter") return; const q=search.value.trim().toLowerCase(); if(!q) return; const exact=tiles.find(({item})=>String(item.barcode||item.sku||"").toLowerCase()===q && !item.__out); const vis=tiles.find(({btn,item})=>btn.style.display!=="none" && !item.__out); const hit=exact||vis; if(hit){ addItem(hit.item); } else { flash("No match for “"+search.value.trim()+"”"); } }; cat.appendChild(search); } + const catsInOrder=[]; FLOW.catalog.forEach(it=>{ const c=it.category||"other"; if(catsInOrder.indexOf(c)<0) catsInOrder.push(c); }); + if(FLOW.catalog.length>8 && catsInOrder.length>1){ + const tabs=document.createElement("div"); tabs.className="opts cattabs"; tabs.style.cssText="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"; + const mk=(key,label)=>{ const t=document.createElement("button"); t.textContent=label; if(activeCat===key) t.className="sel"; + t.onclick=()=>{ activeCat=key; draft.__cat=key; tabs.querySelectorAll("button").forEach(x=>x.classList.remove("sel")); t.classList.add("sel"); applyFilter(); }; + tabs.appendChild(t); }; + mk("__all","All"); catsInOrder.forEach(c=>mk(c,catLabel(c))); + cat.appendChild(tabs); + } const grid=document.createElement("div"); grid.className="grid"; FLOW.catalog.forEach(item=>{ const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const out=is86(item)||isOutOfStock(item); @@ -1916,6 +1935,7 @@ grid.appendChild(b); }); cat.appendChild(grid); app.appendChild(cat); + applyFilter(); // honor the active category tab (and any typed search) on first paint if(pending) app.appendChild(configPanel(station)); diff --git a/tests/burgerbarn.js b/tests/burgerbarn.js index 768fcea..44f5de2 100644 --- a/tests/burgerbarn.js +++ b/tests/burgerbarn.js @@ -21,13 +21,21 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i const cfg = await p.evaluate(() => { const f = window.__build.flow, byName = n => f.catalog.find(i=>i.name===n); + const cats = {}; f.catalog.forEach(i=>{ cats[i.category]=1; }); + const combo = (byName('Bubba Burger').addons||[]).some(a=>/combo/i.test(a.name)); return { name: f.branding.name, bubbaToGrill: (byName('Bubba Burger').path||[]).join()==='grill', onionToFry: (byName('Onion Rings').path||[]).join()==='fry', shakeToShakes: (byName('Milkshake').path||[]).join()==='shakes', + saladToPrep: (byName('House Salad').path||[]).join()==='prep', + chimiToFry: (byName('Chicken Chimichanga').path||[]).join()==='fry', + fajitaToGrill: (byName('Flaming Fajitas').path||[]).join()==='grill', specialPar: byName('Blue Plate Special').par===10, hasTip: !!f.endpoints.payment.tip, + hasCombo: combo, + catCount: Object.keys(cats).length, + hasCatLabels: !!(f.catLabels && f.catLabels.burger), itemCount: f.catalog.length }; }); @@ -35,6 +43,9 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i // drive the ACTUAL generated POS in the live preview: one order fans out to grill + fry const fr = p.frameLocator('#preview'); await fr.getByText('Front Counter', { exact:false }).first().click(); + // 🍔 Toast-style category tabs render for a big menu (wait for the ordering screen to paint before counting) + await fr.locator('.cattabs button').first().waitFor(); + const tabCount = await fr.locator('.cattabs button').count(); await fr.getByText('Bubba Burger', { exact:false }).first().click(); await fr.getByRole('button',{ name:/^Single/ }).first().click(); // required Patty choice await fr.getByRole('button',{ name:/Add to order/ }).click(); @@ -54,12 +65,16 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i console.log('grill:', grill.replace(/\n+/g,' | ')); console.log('fry:', fry.replace(/\n+/g,' | ')); console.log('\n=== RESULTS ==='); - console.log('real menu built ('+cfg.itemCount+' items), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=18); - console.log('items routed right (burger→grill, rings→fry, shake→shakes):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes); + console.log('full menu built ('+cfg.itemCount+' items across '+cfg.catCount+' categories), Hamburger Barn:', cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10); + console.log('items routed right (burger→grill, rings→fry, shake→shakes, salad→prep, chimi→fry, fajita→grill):', cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill); + console.log('Toast-style: "make it a combo" upsell + category labels:', cfg.hasCombo && cfg.hasCatLabels); console.log('daily special has an 86 count; tips on:', cfg.specialPar && cfg.hasTip); + console.log('Toast-style category tabs render on the ordering screen ('+tabCount+' tabs):', tabCount>=5); console.log('live POS fan-out: Grill sees the burger only:', grillOk); console.log('live POS fan-out: Fry sees the onion rings only:', fryOk); console.log('console errors:', errors.length?errors:'NONE'); - const ok = cfg.name==='Hamburger Barn' && cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.specialPar && cfg.hasTip && grillOk && fryOk && !errors.length; + const ok = cfg.name==='Hamburger Barn' && cfg.itemCount>=40 && cfg.catCount>=10 && + cfg.bubbaToGrill && cfg.onionToFry && cfg.shakeToShakes && cfg.saladToPrep && cfg.chimiToFry && cfg.fajitaToGrill && + cfg.hasCombo && cfg.hasCatLabels && cfg.specialPar && cfg.hasTip && tabCount>=5 && grillOk && fryOk && !errors.length; process.exit(ok?0:1); })().catch(e => { console.error('FATAL', e); process.exit(2); }); From 8106aec54a6b2a2f09671a2834c75429427b8fb6 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 12:51:35 -0500 Subject: [PATCH 02/19] Fix burgerbarn test: select the trade button by its real label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line 18 filtered button.trade by hasText 'burger-joint', but the trade button is labeled "Hamburger Barn (burger joint)" (label + blurb) — no hyphen — so the click timed out (30s) and failed the whole suite. This mismatch predates this branch. Filter by "Hamburger Barn" (unique to this template) instead. Verified the full flow live in-browser: trade select → build → Front Counter → Bubba (Single) + Onion Rings → Send → Grill sees the burger only, Fry sees the onion rings only; category tabs render; zero console errors. Co-Authored-By: Claude Opus 4.8 --- tests/burgerbarn.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/burgerbarn.js b/tests/burgerbarn.js index 44f5de2..4cd50ed 100644 --- a/tests/burgerbarn.js +++ b/tests/burgerbarn.js @@ -14,8 +14,8 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i p.on('pageerror', e => errors.push('pageerror: '+e.message)); await p.goto(`http://127.0.0.1:${port}/builder.html`); - // build the real Hamburger Barn template - await p.locator('button.trade').filter({ hasText: 'burger-joint' }).click(); + // build the real Hamburger Barn template (its trade button is the only one labeled "Hamburger Barn") + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); await p.getByRole('button',{name:/Build it for me/}).click(); await p.waitForFunction(() => window.__build && window.__build.html); From 8df6f129152a826f009462ae1e391b606ed953f7 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 17:22:11 -0500 Subject: [PATCH 03/19] Server flow: liquor age gate by TYPED birthdate (computes age, refuses under-age) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-service server flow sells alcohol, and a real POS must card by a typed date of birth — not a "looks fine" tap. The engine already had an age-restricted gate on checkout (item.ageRestricted → block tenders + an ID-check audit log), but it cleared on a single "✓ ID checked" button. Now: - pos.html: new ageFromDOB() parses a typed birthdate (M/D/YYYY or YYYY-MM-DD), rejects impossible/future dates, and returns whole-year age. The checkout gate now takes a birthdate, computes the age, and REFUSES if under the requirement (logs "SALE REFUSED", tenders stay blocked) — no override tap. An of-age entry verifies, clears the gate, opens tenders. The audit log records result + computed age + when + station, but NOT the raw DOB (privacy: the defensible record is "age verified/refused," not the customer's date of birth). - builder.html: the full-service template's bar drinks (House Wine, Draft Beer) are marked ageRestricted:21 so the gate is real in that flow. - tests/age-gate.js: drives the full server flow (ring beer → fire → pay), and pins the age math, the under-age refusal + log, the of-age clear + tenders, and that the raw DOB is never stored. Verified live in-browser: minor (16) refused + logged, of-age (36) verified + "Pay by card" appears, zero console errors. Co-Authored-By: Claude Opus 4.8 --- builder.html | 4 +-- pos.html | 37 +++++++++++++++++----- tests/age-gate.js | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 tests/age-gate.js diff --git a/builder.html b/builder.html index d0ef1f5..86a7375 100644 --- a/builder.html +++ b/builder.html @@ -457,8 +457,8 @@ {id:"steak",name:"Ribeye",price:34,category:"entree",path:["line"],course:2,modifiers:[{group:"Temp",required:true,options:[{name:"Rare",price:0},{name:"Med rare",price:0},{name:"Medium",price:0},{name:"Well",price:0}]}]}, {id:"salmon",name:"Salmon",price:26,category:"entree",path:["line"],course:2}, {id:"burger",name:"Bistro Burger",price:16,category:"entree",path:["line"],course:2}, - {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1}, - {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1}, + {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1,ageRestricted:21}, + {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1,ageRestricted:21}, {id:"cake",name:"Chocolate Cake",price:10,category:"dessert",path:["cold"],course:3} ], stations:[ diff --git a/pos.html b/pos.html index 5ed9b9e..4c15eda 100644 --- a/pos.html +++ b/pos.html @@ -560,6 +560,16 @@ // age-restricted items (tobacco/alcohol/lottery): an item's ageRestricted (true→21, or a number) drives an ID-check gate at checkout function lineAge(l){ const it=FLOW.catalog.find(i=>i.id===l.catId); const a=it&&it.ageRestricted; return a===true?21:(+a||0); } function orderAgeReq(r){ return Math.max(0, ...((r.lines||[]).filter(l=>!l.returned).map(lineAge))); } +// whole-year age from a TYPED birthdate — accepts YYYY-MM-DD or M/D/YYYY; null if it isn't a real date. +// We compute and gate on the age; we deliberately do NOT store the raw DOB (the defensible record is +// "age N was verified/refused, when, at which station" — not the customer's date of birth). +function ageFromDOB(s){ s=String(s||"").trim(); if(!s) return null; + var m1=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/), m2=s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/), y,m,d; + if(m1){ y=+m1[1]; m=+m1[2]; d=+m1[3]; } else if(m2){ m=+m2[1]; d=+m2[2]; y=+m2[3]; } else return null; + if(m<1||m>12||d<1||d>31) return null; + var dob=new Date(y,m-1,d); if(dob.getFullYear()!==y||dob.getMonth()!==m-1||dob.getDate()!==d) return null; // rejects 02/30 etc. + if(dob>new Date()) return null; + var now=new Date(), a=now.getFullYear()-y; if(now.getMonth() s + t.amount, 0); } function recordBalance(r){ return +(recordTotal(r) - recordPaid(r)).toFixed(2); } function firstPerformer(r){ const l=r.lines.find(x=>x.performer); return l?l.performer:null; } @@ -2339,15 +2349,26 @@ const ageReq=orderAgeReq(r); if(ageReq && !r.ageVerified){ const gate=document.createElement("div"); gate.className="mgroup"; gate.style.cssText="border:1px solid var(--bad);border-radius:8px;padding:10px;margin-top:10px"; - gate.innerHTML="
⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID before selling.
"; - const ok=document.createElement("button"); ok.className="primary"; ok.style.marginTop="8px"; ok.textContent="✓ ID checked — customer is "+ageReq+"+"; - ok.onclick=()=>{ const st=boundStation(); const at=new Date().toISOString().slice(11,16); - r.ageVerified={ req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" }; // defensible record: what was required, when, at which station - DB.ageLog=DB.ageLog||[]; DB.ageLog.push({ number:r.number, req:ageReq, at, day:todayStr(), station:(st&&st.label)||"" }); - logActivity("idcheck", "ID checked — customer verified "+ageReq+"+", r.number); + gate.innerHTML="
⚠ Age-restricted order
This order has items that require the customer to be "+ageReq+"+. Check a photo ID and type the date of birth from it.
"; + const row=document.createElement("div"); row.className="opts"; row.style.marginTop="8px"; + const dob=document.createElement("input"); dob.id="dobInput"; dob.placeholder="Birthdate MM/DD/YYYY"; dob.autocomplete="off"; dob.style.minWidth="180px"; + const ok=document.createElement("button"); ok.className="primary"; ok.textContent="Verify age"; + // type the birthdate, we compute the age and REFUSE if it's under the requirement — no override tap + const verify=()=>{ const raw=(document.getElementById("dobInput")||{}).value; const age=ageFromDOB(raw); + const st=boundStation(); const at=new Date().toISOString().slice(11,16); + if(age==null){ flash("Type the date of birth as MM/DD/YYYY"); return; } + DB.ageLog=DB.ageLog||[]; + if(age{ if(e.key==="Enter") verify(); }; + row.appendChild(dob); row.appendChild(ok); gate.appendChild(row); + c.appendChild(gate); app.appendChild(c); return; // no tenders until a valid, of-age birthdate is entered } // split the check evenly N ways (opt-in) — each tap takes one equal share until the balance clears if(FLOW.endpoints.payment.split){ diff --git a/tests/age-gate.js b/tests/age-gate.js new file mode 100644 index 0000000..8bc587b --- /dev/null +++ b/tests/age-gate.js @@ -0,0 +1,81 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔞 A server selling alcohol must card by TYPED BIRTHDATE — the POS computes the age and refuses an under-age +// sale, no override tap. This drives the real full-service flow: ring a beer, fire it, then at payment the age +// gate demands a birthdate; an under-21 DOB is refused and logged, an of-age DOB clears the gate and opens tenders. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + // build the full-service (server) template — it has a bar with age-restricted drinks + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + // unit-check the age math + the restricted flag, straight out of the generated engine (same-origin iframe) + const unit = await p.evaluate(async () => { + const w = document.querySelector('#preview').contentWindow; + for (let i=0;i<60 && !(w.ageFromDOB && w.CUSTOMPOS_FLOW);i++) await new Promise(r=>setTimeout(r,100)); + const f = w.ageFromDOB, beer = w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='Draft Beer'); + return { minorUnder21: f('01/15/2010') < 21, adultOver21: f('06/20/1990') >= 21, + garbageNull: f('hello')===null, badDateNull: f('02/30/2000')===null, futureNull: f('01/01/2050')===null, + isoWorks: typeof f('1995-03-02')==='number', beerRestricted: beer && beer.ageRestricted===21 }; + }); + + // drive the full server flow to a READY alcohol order, then exercise the gate + const gate = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const clickBtn=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + const clickLink=re=>{const el=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + clickBtn(/^Got it/); clickBtn(/^Server Station/); await wait(150); + const beer=[...doc().querySelectorAll('.tile')].find(t=>/Draft Beer/.test(t.textContent)); if(beer)beer.click(); await wait(100); + clickBtn(/Send order/); await wait(150); + clickLink(/change station/); await wait(120); clickBtn(/^Bar/); await wait(150); + clickBtn(/Mark done here/); await wait(200); + clickLink(/change station/); await wait(120); clickBtn(/^Server Station/); await wait(200); + const readyStatus=(w.loadDB().records||[]).map(r=>r.status); + const openIt=[...doc().querySelectorAll('button, .card, .line, a')].find(x=>/Draft Beer/.test(x.textContent||'')); if(openIt)openIt.click(); await wait(200); + const gateShown=!!doc().querySelector('#dobInput'); + // under-age → refused, still gated, logged + doc().querySelector('#dobInput').value='01/15/2010'; + (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200); + const recA=(w.loadDB().records||[])[0]; + const minorBlocked = !recA.ageVerified && !!doc().querySelector('#dobInput'); + const refusedLogged = (w.loadDB().ageLog||[]).some(a=>a.result==='refused' && a.age<21); + // of-age → verified, gate clears, tenders open + doc().querySelector('#dobInput').value='06/20/1990'; + (function(){const v=[...doc().querySelectorAll('button')].find(x=>/Verify age/.test(x.textContent));if(v)v.click();})(); await wait(200); + const recB=(w.loadDB().records||[])[0]; + const adultVerified = !!(recB.ageVerified && recB.ageVerified.age>=21 && recB.ageVerified.method==='dob'); + const gateGone = !doc().querySelector('#dobInput'); + const tenderShown = [...doc().querySelectorAll('button')].some(x=>/Cash|Card|Pay|Charge|Tender/.test(x.textContent)); + const noRawDob = !('dob' in recB.ageVerified) && !('birthdate' in recB.ageVerified); // we store the computed age, never the raw DOB + return { readyStatus, gateShown, minorBlocked, refusedLogged, adultVerified, gateGone, tenderShown, noRawDob }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('age math (minor<21, adult>=21, iso ok, garbage/badDate/future null):', unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull); + console.log('bar drink is age-restricted (21):', unit.beerRestricted); + console.log('order fires to READY and the age gate demands a birthdate:', JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown); + console.log('under-age birthdate is REFUSED, still gated, and logged:', gate.minorBlocked && gate.refusedLogged); + console.log('of-age birthdate verifies, clears the gate, opens tenders:', gate.adultVerified && gate.gateGone && gate.tenderShown); + console.log('the raw DOB is NOT stored (only the computed age):', gate.noRawDob); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = unit.minorUnder21 && unit.adultOver21 && unit.isoWorks && unit.garbageNull && unit.badDateNull && unit.futureNull && unit.beerRestricted + && JSON.stringify(gate.readyStatus)==='["READY"]' && gate.gateShown && gate.minorBlocked && gate.refusedLogged + && gate.adultVerified && gate.gateGone && gate.tenderShown && gate.noRawDob && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 4b6b6d97e721712efc8d77861372d8987cf471e8 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 17:27:33 -0500 Subject: [PATCH 04/19] Update the ID-check audit card + age-check test for the birthdate gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tests/age-check.js drove the old "✓ ID checked" confirm button, which the birthdate gate replaced — so it timed out. Update it to type an of-age DOB and click "Verify age". Also make the Office "ID checks (audit)" card result-aware: a refused check now reads "REFUSED — under 21 (age N)" in red instead of being mislabeled "verified", since refusals are logged too. Co-Authored-By: Claude Opus 4.8 --- pos.html | 4 +++- tests/age-check.js | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pos.html b/pos.html index 4c15eda..b250924 100644 --- a/pos.html +++ b/pos.html @@ -1125,7 +1125,9 @@ // ---- ID checks (audit): defensible record of every age-restricted sale that was verified ---- { const idlog=(DB.ageLog||[]).filter(a=>a.day===todayStr()); if(idlog.length){ const ic=document.createElement("div"); ic.className="card"; ic.innerHTML="
ID checks (audit) "+idlog.length+" today
"; - idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+a.at+" · #"+a.number+" — verified "+a.req+"+"+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } } + idlog.slice(-8).reverse().forEach(a=>{ const row=document.createElement("div"); row.className="line"; + const res=a.result==="refused" ? ("REFUSED — under "+a.req+"") : ("verified "+a.req+"+"); + row.innerHTML=""+a.at+" · #"+a.number+" — "+res+(a.age!=null?" (age "+a.age+")":"")+""+(a.station||"")+""; ic.appendChild(row); }); app.appendChild(ic); } } // ---- commission (only when the trade pays commission) ---- const pct=(FLOW.commission&&FLOW.commission.pct)||0; if(FLOW.performers){ diff --git a/tests/age-check.js b/tests/age-check.js index 8872cfd..5a18a3a 100644 --- a/tests/age-check.js +++ b/tests/age-check.js @@ -31,8 +31,9 @@ const FLOW = { const gated = await T(); const blockedOk = /Age-restricted order/.test(gated) && /21\+/.test(gated) && !/Take payment/.test(gated); - // check the ID -> payment unlocks - await p.getByRole('button',{name:/ID checked/}).click(); + // type an of-age birthdate -> payment unlocks + await p.locator('#dobInput').fill('06/20/1990'); + await p.getByRole('button',{name:/Verify age/}).click(); const unlocked = await T(); const unlockedOk = /Take payment/.test(unlocked) && !/Age-restricted order/.test(unlocked); await p.getByRole('button',{name:/Take payment/}).first().click(); From bcb8ff7f10d1f26037452ef6085c7c430cb70176 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 20:32:05 -0500 Subject: [PATCH 05/19] docs: restaurant POS capability map + prioritized roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay out what a full restaurant POS needs (measured against Toast/Square/Aloha/ TouchBistro/Revel), scored against what the engine already does (verified against pos.html + the tests/ suite), plus the gap list and a P1–P4 build order. The vertical now runs DOWN this plan with the owner steering, instead of each feature being spelled out. Top gaps: seat-numbered ordering, split/pay by seat, comps, server assignment, table transfer, day-part/happy-hour pricing, reservations. Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/RESTAURANT-ROADMAP.md diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md new file mode 100644 index 0000000..22c5537 --- /dev/null +++ b/docs/RESTAURANT-ROADMAP.md @@ -0,0 +1,139 @@ +# 🍽️ Restaurant POS — capability map & roadmap + +*What a full restaurant POS actually needs (measured against Toast / Square for Restaurants / Aloha / TouchBistro +/ Revel), scored against what the customPOS engine already does, and a build order so the vertical is driven by a +plan — not spelled out feature by feature.* + +> Read with [RESTAURANT-VERTICAL.md](RESTAURANT-VERTICAL.md) (engine design) and +> [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) (the Stage-2 proving ground + Toast grievance capture). + +**Legend:** ✅ built · 🟡 partial / close · ❌ not yet. Status verified against `pos.html` and the `tests/` suite +(the test names in parentheses are the proof it exists). + +## 1. Front of house / the server flow +| Capability | Status | Notes | +|---|---|---| +| Take & send orders from a station | ✅ | central "server" station (`rules-and-fanout`) | +| Floor plan, sections, drag-drop designer | ✅ | `floor.js`, `floor-designer.js` | +| Table states (open→seated→ordered→…) + turn timing + covers | ✅ | `floor.js`, `turns.js` | +| Coursing — apps/drinks first, fire entrées, add dessert later | ✅ | `coursing.js` | +| **Order by SEAT number** (seat 2's ribeye) | ❌ | "seats" today = table capacity only — **top gap** | +| Split the check | 🟡 | even 2/3/4-way (`split.js`) — **not by seat or by item** | +| **Per-seat / per-item checks** | ❌ | each guest pays their own — the other half of the seat gap | +| Transfer / merge tables, move a guest | ❌ | | +| Server assignment (sections → a server, "my tables") | ❌ | staff exist (`worker.js`), but tables aren't owned | +| Reservations + waitlist | ❌ | `booking.js` is appointment-style, not restaurant reso/waitlist | + +## 2. Kitchen / expo +| Capability | Status | Notes | +|---|---|---| +| Per-item station routing / fan-out | ✅ | one order → grill + fry + bar (`rules-and-fanout`) | +| Kitchen Display (KDS) with aging timers + bump | ✅ | `kds.js` | +| Printed kitchen tickets routed by station | ✅ | `print.js` | +| 86 / par counts | ✅ | live, blocks the item everywhere | +| Course fire / hold to the line | ✅ | `coursing.js` | +| Allergen / prep flags on the ticket | ✅ | item `flags` | +| Seat # shown on the KDS ticket | 🟡 | rides on the seat build | + +## 3. Menu & items +| Capability | Status | Notes | +|---|---|---| +| Categories + Toast-style category tabs | ✅ | new (PR #5) | +| Modifiers (required/optional), add-ons, price deltas | ✅ | e.g. Patty single/double, cheese, bacon | +| Combos / upsell ("make it a combo") | ✅ | add-on combos | +| Open price / by-weight | ✅ | `custom-price.js`, `by-weight.js` | +| **Day-part menus** (breakfast till 11) | ❌ | | +| **Time-based / happy-hour pricing** | ❌ | | + +## 4. Payments & checkout +| Capability | Status | Notes | +|---|---|---| +| Cash w/ change, card | ✅ | `cash-change.js`, `payments.js`, `idempotent-charge.js` | +| Tips + presets + **tip pooling** | ✅ | `tippool.js` | +| Discounts / coupons | ✅ | `coupon.js` | +| **Comps** (free item, reason-coded, manager-approved) | ❌ | distinct from a discount | +| Voids / returns with audit + manager approval | ✅ | `line-return.js`, `approvals.js` | +| Gift cards + loyalty points | ✅ | `loyalty-giftcards.js` | +| House accounts / A/R, deposits, round-up | ✅ | `house-account.js`, `deposit-*.js`, `roundup.js` | +| Receipt — print & email | ✅ | `print.js`, `notify.js`, `tax-receipt.js` | +| **Pay-at-table / QR pay** | ❌ | | + +## 5. Order types +| Capability | Status | Notes | +|---|---|---| +| Dine-in | ✅ | | +| Takeout / to-go | ✅ | | +| Own delivery routes (driver, stops, confirm) | ✅ | `route.js` | +| **3rd-party delivery (DoorDash/UberEats) intake** | ❌ | | +| **Online ordering / QR menu** | ❌ | | +| **Drive-thru mode** | ❌ | if the concept needs it | + +## 6. Staff & labor +| Capability | Status | Notes | +|---|---|---| +| PIN login + roles | ✅ | | +| Time clock, breaks, schedule, time-off | ✅ | `timeclock.js`, `breaks.js`, `schedule.js`, `timeoff.js` | +| Labor cost vs sales, paystubs | ✅ | `labor-cost.js`, `paystub.js` | +| Manager approvals (voids/refunds/comps) | ✅ | `approvals.js` | + +## 7. Inventory, reporting, back office +| Capability | Status | Notes | +|---|---|---| +| Stock tracking, reorder, low-stock, margin | ✅ | `inventory.js`, `margin.js` | +| Sales / top items / P&L / expenses | ✅ | `report-*.js`, `top-items.js`, `expenses-pnl.js` | +| Busy hours + table-turn analytics | ✅ | `busy-hours.js`, `turns.js` | +| Activity / audit log + CSV | ✅ | `activity-log.js` | +| Multi-store | ✅ | `multistore.js` | +| Accounting export (QuickBooks) | 🟡 | exists on the Ozark instance; not in the shared engine | + +## 8. Compliance, platform & reliability (already strong) +| Capability | Status | Notes | +|---|---|---| +| Age gate by **typed birthdate** (computes age, refuses under-age) | ✅ | new (PR #5); `age-gate.js`, `age-check.js` | +| Tax: rate, zones, tax classes, tax-included | ✅ | `tax-*.js` | +| Offline-first single file / PWA | ✅ | `pwa.js`, `first-run.js` | +| Sync hub + delta + conflict merge, multi-register | ✅ | `sync.js`, `delta-*.js`, `hub-merge.js` | +| Encrypted, verified backups | ✅ | `backup*.js` | +| Crash/incident reporting + security guards | ✅ | `crash-reporter.js`, `injection.js`, `blob-guard.js` | + +--- + +## The gap list (what a full-service line still needs) +Everything not ✅ above, collected: + +1. **Order by seat number** (+ seat on the KDS ticket) +2. **Split & pay by seat / by item** (not just even N-way) +3. **Comps** — free item, reason-coded, manager-approved (separate from a discount) +4. **Server assignment** — sections/tables owned by a server; a "my tables" view +5. **Transfer / merge tables**, move a guest between seats/tables +6. **Day-part menus + happy-hour / time-based pricing** +7. **Reservations + waitlist** +8. **Pay-at-table / QR pay** +9. **Online ordering / QR menu**, and 3rd-party delivery intake +10. **Drive-thru mode** (only if a concept needs it) + +## Build order (proposed) +Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by how much each unlocks the rest. + +- **P1 — the heart of the server flow** + 1. Seat-numbered ordering (assign each line to a seat; show seats on the ticket & KDS) + 2. Split & pay **by seat** (falls out of #1) and by item + 3. Comps with reason + manager approval +- **P2 — runs a real dining room** + 4. Server assignment + "my tables" + 5. Transfer / merge tables, move a guest + 6. Day-part menus + happy-hour pricing +- **P3 — reservations & the door** + 7. Reservations + waitlist +- **P4 — off-premise & self-serve** + 8. Pay-at-table / QR pay + 9. Online ordering / QR menu; 3rd-party delivery intake + 10. Drive-thru (if needed) + +Each item ships through the safety pipeline (draft → gates/tests → live preview → owner approval → deploy → rollback), +with a test pinning the behavior — same as every change in this repo. + +## How this changes the way we work +This doc is the plan of record: the build runs **down this list**, and the owner **steers** (reprioritize, veto, +add from real Burger Barn grievances) instead of spelling each feature out. When the owner works Toast at Burger +Barn, grievances captured in [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) get slotted into this order. From bea11b2f38926c0f6a72fef2363c7363dd53e589 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 20:48:42 -0500 Subject: [PATCH 06/19] Server flow P1: seat-by-seat ordering (order by seat number) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dine-in seat-numbered ordering — the heart of the server flow. At intake, once a table is chosen a seat picker appears (Whole table + Seat 1..N, capacity-aware, "+ seat" to add more). The chosen seat stamps onto each line (draft + record) and rides through everywhere: the draft list, the KDS ticket, the production station, the server's order card, and the printed kitchen ticket all show 👤 S{n}. Changing/clearing the table resets the seat. This is what per-seat checks build on next (P1 #2). - pos.html: seatChip() helper; seat picker in renderIntake; seat stamped in addLine; seat shown on KDS/station/order-card/printed-ticket line renders. - tests/seat-order.js: drives table→Seat 1 Calamari, Seat 2 Ribeye→Send, pins the picker, the per-seat chips, seats on the record, and the kitchen showing seats. - docs/RESTAURANT-ROADMAP.md: mark P1 #1 shipped. Verified live in-browser: Calamari→S1, Ribeye→S2, seats on the record and shown at the kitchen line, zero console errors. Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 6 ++-- pos.html | 35 ++++++++++++++++----- tests/seat-order.js | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 tests/seat-order.js diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 22c5537..da09de4 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -17,7 +17,7 @@ plan — not spelled out feature by feature.* | Floor plan, sections, drag-drop designer | ✅ | `floor.js`, `floor-designer.js` | | Table states (open→seated→ordered→…) + turn timing + covers | ✅ | `floor.js`, `turns.js` | | Coursing — apps/drinks first, fire entrées, add dessert later | ✅ | `coursing.js` | -| **Order by SEAT number** (seat 2's ribeye) | ❌ | "seats" today = table capacity only — **top gap** | +| **Order by SEAT number** (seat 2's ribeye) | ✅ | seat picker at intake; seat rides the line → KDS/station/ticket/check (`seat-order.js`) | | Split the check | 🟡 | even 2/3/4-way (`split.js`) — **not by seat or by item** | | **Per-seat / per-item checks** | ❌ | each guest pays their own — the other half of the seat gap | | Transfer / merge tables, move a guest | ❌ | | @@ -116,8 +116,8 @@ Everything not ✅ above, collected: Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by how much each unlocks the rest. - **P1 — the heart of the server flow** - 1. Seat-numbered ordering (assign each line to a seat; show seats on the ticket & KDS) - 2. Split & pay **by seat** (falls out of #1) and by item + 1. ✅ Seat-numbered ordering (assign each line to a seat; seats show on the ticket & KDS) — *shipped* + 2. Split & pay **by seat** (falls out of #1) and by item — *next* 3. Comps with reason + manager approval - **P2 — runs a real dining room** 4. Server assignment + "my tables" diff --git a/pos.html b/pos.html index b250924..d9fd632 100644 --- a/pos.html +++ b/pos.html @@ -523,6 +523,8 @@ function storeForOrder(cust){ return (cust && cust.storeId) || homeStore() || defaultStoreId(); } // an order's home store function storeChip(r){ if(!storesOn() || !r.storeId) return ""; const nm=storeName(r.storeId); return nm ? " 🏬 "+nm+"" : ""; } function tableChip(r){ return r.tableId ? " 🍽 "+tableLabel(r.tableId)+"" : ""; } +// 👤 which seat a line belongs to (dine-in seat-by-seat ordering). Blank when the line isn't tied to a seat. +function seatChip(l){ return (l&&l.seat) ? " 👤 S"+l.seat+"" : ""; } function assembledAt(r){ if(!storesOn() || !r.storeId) return ""; const p=plantStore(); return (p && r.storeId!==p.id) ? "assembled at "+p.name : ""; } // a drop-store order is done at the plant /* ===================================== MONEY MATH ===================================== */ @@ -1418,7 +1420,7 @@ const fc=r.firedCourse||1; c.innerHTML="
#"+r.number+" "+kdsChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
"; r.lines.filter(l=>courseReleased(r,l)).forEach(l=>{ const row=document.createElement("div"); row.className="line"; const where=lineComplete(l)?"ready":("at "+l.path[l.stage]); - row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+where+""; c.appendChild(row); }); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+where+""; c.appendChild(row); }); // held courses waiting to be fired if(coursingOn()){ const held=r.lines.filter(l=>!courseReleased(r,l)); if(held.length){ const h=document.createElement("div"); h.className="muted"; h.style.marginTop="4px"; h.textContent="held: "+held.map(l=>l.qty+"× "+l.name).join(", ")+" ("+courseLabel(fc+1)+")"; c.appendChild(h); } } @@ -1893,10 +1895,26 @@ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="🍽 Table:"; tb.appendChild(lbl); const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap"; (FLOW.floor.tables||[]).forEach(t=>{ const b=document.createElement("button"); const open=tableOpenTotal(t.id); if(draft.tableId===t.id) b.className="sel"; - b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); render(); }; opts.appendChild(b); }); - const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; render(); }; opts.appendChild(none); + b.textContent=(t.label||t.id)+(open>0.0001?" · "+money(open):""); b.onclick=()=>{ draft.tableId=(draft.tableId===t.id?null:t.id); draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(b); }); + const none=document.createElement("button"); if(!draft.tableId) none.className="sel"; none.textContent="No table"; none.onclick=()=>{ draft.tableId=null; draft.seat=null; draft.__seatMax=0; render(); }; opts.appendChild(none); tb.appendChild(opts); app.appendChild(tb); } + // 👤 seat picker — dine-in seat-by-seat ordering. Pick a seat and the items you tap ring to that seat (so the + // kitchen and the check know whose is whose, and the table can split by seat). "Whole table" = shared/no seat. + // Seats shown = the table's capacity, or higher if the ticket already used more, or the server added seats. + if(draft.tableId){ + const cap=tableSeats(draft.tableId)||0; + const usedMax=Math.max(0, ...(draft.lines||[]).map(l=>+l.seat||0)); + const shown=Math.max(cap, usedMax, draft.__seatMax||0, 4); // always offer at least a few + const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px;margin-bottom:10px"; + const slbl=document.createElement("span"); slbl.className="muted"; slbl.style.marginRight="8px"; slbl.textContent="👤 Seat:"; sc.appendChild(slbl); + const sopts=document.createElement("div"); sopts.className="opts"; sopts.style.display="inline-flex"; sopts.style.flexWrap="wrap"; + const whole=document.createElement("button"); if(!draft.seat) whole.className="sel"; whole.textContent="Whole table"; whole.onclick=()=>{ draft.seat=null; render(); }; sopts.appendChild(whole); + for(let n=1;n<=shown;n++){ const cnt=(draft.lines||[]).filter(l=>(+l.seat||0)===n).length; const b=document.createElement("button"); if(draft.seat===n) b.className="sel"; + b.textContent="Seat "+n+(cnt?" ("+cnt+")":""); b.onclick=()=>{ draft.seat=n; render(); }; sopts.appendChild(b); } + const add=document.createElement("button"); add.textContent="+ seat"; add.title="Add another seat"; add.onclick=()=>{ draft.__seatMax=shown+1; draft.seat=shown+1; render(); }; sopts.appendChild(add); + sc.appendChild(sopts); app.appendChild(sc); + } const cat=document.createElement("div"); cat.innerHTML="

New order

"; // search / barcode-scan box — shown for big menus or when a scanner is configured (keyboard-wedge types code + Enter) const tiles=[]; @@ -1954,7 +1972,7 @@ const c=document.createElement("div"); c.className="card"; if(!draft.lines.length){ c.innerHTML="
Tap items to build the order.
"; } else { draft.lines.forEach((l,idx)=>{ const row=document.createElement("div"); row.className="line"; - row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":""); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+(l.performer?" "+l.performer+"":"")+tagChip(l)+""+((l.path||[]).join(' → ')||'—')+""+(station.view.money?""+money(l.price*l.qty)+"":""); if(station.view.money){ // quantity steppers + per-line price edit (haggling, custom items, ring 6 in one tap) const dec=document.createElement("button"); dec.textContent="−"; dec.title="one fewer"; dec.style.cssText="padding:2px 9px;margin-left:6px"; dec.onclick=()=>{ if(l.qty>1) l.qty--; else { logActivity("void", "removed "+l.name+" ("+money(l.price)+") from an unsent ticket", null, l.price); draft.lines.splice(idx,1); } render(); }; row.appendChild(dec); const inc=document.createElement("button"); inc.textContent="+"; inc.title="one more"; inc.style.cssText="padding:2px 9px;margin-left:4px"; inc.onclick=()=>{ l.qty++; render(); }; row.appendChild(inc); @@ -2056,6 +2074,7 @@ const modLabel=mods.map(m=>m.name).filter(Boolean).join(", "); const cart = editingId ? ((DB.records||[]).find(r=>r.id===editingId)||draft) : draft; // detailing edits the order; intake edits the draft cart.lines.push({ id:"L"+Math.round(performance.now())+Math.floor(Math.random()*1e4), catId:item.id, name:item.name, modLabel, flags:(flags||[]).slice(), performer:performer||null, price, qty:1, category:item.category, path:item.path.slice(), stage:0, course:item.course||1, + seat: (editingId ? null : (draft && draft.seat) || null), // 👤 dine-in: which seat this line rings to (null = whole table) serialized: !!item.serialized, tag: item.serialized ? genUnitTag() : undefined, tagLabel: item.serialized ? (item.tagLabel||"Tag") : undefined }); if(editingId) saveDB(DB); render(); @@ -2154,7 +2173,7 @@ const flagHtml=(l.flags||[]).map(f=>{ hasFlags=true; return " ⚠ "+f+""; }).join(""); const perfHtml=l.performer?" "+l.performer+"":""; if(station.reconcile){ const ck=reconChecked(r,station,l); if(!ck) reconOk=false; const cb=document.createElement("button"); cb.textContent=ck?"✓":"○"; cb.title="Count this piece out"; cb.style.cssText="padding:2px 9px;margin-right:4px"; if(ck) cb.className="sel"; cb.onclick=()=>reconToggle(r,station,l); row.appendChild(cb); } - row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); }); + row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+perfHtml+flagHtml+tagChip(l)+bagChip(l)+""+(next?"→ "+next:"→ ready")+""); c.appendChild(row); }); if(station.bag){ const bagged=mine.some(l=>l.bag); const bw=document.createElement("div"); bw.className="mgroup"; bw.innerHTML="
Assembly bags
"; const bb=document.createElement("button"); bb.textContent=bagged?("🧺 Re-bag ("+((r.bagsAt&&r.bagsAt[station.id])||0)+" bag(s))"):("🧺 Bag these "+pieceCount(mine)+" piece(s)"); bb.onclick=()=>bagOrder(r,station); bw.appendChild(bb); c.appendChild(bw); } if(printOn()){ const pw=document.createElement("div"); pw.className="mgroup"; pw.innerHTML="
Print
"; @@ -2204,7 +2223,7 @@ const c=document.createElement("div"); c.className="card"; if(!rec.lines.length){ c.innerHTML="
No pieces yet — tap items above.
"; } else rec.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; - row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":"")); + row.insertAdjacentHTML("beforeend",""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+tagChip(l)+""+(station.view.money?""+money(l.price*l.qty)+"":"")); if(l.serialized){ const tb=document.createElement("button"); tb.textContent="🏷 "+(l.tag||"tag"); tb.title="Scan / set the "+(l.tagLabel||"tag"); tb.style.cssText="padding:2px 8px;margin-left:6px"; tb.onclick=()=>{ const v=prompt("Scan or type the "+(l.tagLabel||"tag")+" for this "+l.name+":", l.tag||""); if(v!=null&&v.trim()){ l.tag=v.trim(); saveDB(DB); render(); } }; row.appendChild(tb); } const rm=document.createElement("button"); rm.textContent="✕"; rm.title="Remove"; rm.style.cssText="padding:2px 8px;margin-left:6px"; rm.onclick=()=>{ rec.lines=rec.lines.filter(x=>x.id!==l.id); saveDB(DB); render(); }; row.appendChild(rm); c.appendChild(row); }); @@ -2235,7 +2254,7 @@ return job; } function dueDateText(r){ const t=FLOW.timer; if(!t||t.mode!=="due"||!r.ts) return ""; return new Date(r.ts + t.promiseSec*1000).toISOString().slice(0,16).replace("T"," "); } -function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); } +function docLineText(l){ return " "+(l.qty||1)+"x "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.seat?" [Seat "+l.seat+"]":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+(l.bag?" bag "+l.bag:""); } function docTicket(r){ const L=[]; L.push(((FLOW.branding&&FLOW.branding.name)||"Order").toUpperCase()); if(storesOn()&&r.storeId){ L.push(storeName(r.storeId)); const aa=assembledAt(r); if(aa) L.push(aa); } @@ -2274,7 +2293,7 @@ if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="No orders ready to check out."; app.appendChild(e); } recs.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const bal=recordBalance(r); c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; - r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+""+money(l.price*l.qty)+""; c.appendChild(row); }); + r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+money(l.price*l.qty)+""; c.appendChild(row); }); // tax area (destination sales tax): for delivery, tax at the delivery address's rate, not the shop's. // Shown only when tax zones are configured; "Shop" = the base (origin) rate. if(FLOW.endpoints.tax && Array.isArray(FLOW.endpoints.tax.zones) && FLOW.endpoints.tax.zones.length && r.status==="READY"){ diff --git a/tests/seat-order.js b/tests/seat-order.js new file mode 100644 index 0000000..710df9b --- /dev/null +++ b/tests/seat-order.js @@ -0,0 +1,63 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 👤 SEAT-BY-SEAT ordering (full-service server flow): pick a table, pick a seat, and each item rings to that +// seat — so the kitchen and the check know whose is whose. This drives the real flow and pins that the seat +// lands on the line, survives Send onto the record, and shows to the kitchen station. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const clickBtn=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + const clickTile=re=>{const el=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(el){el.click();return true;}return false;}; + clickBtn(/^Got it/); clickBtn(/^Server Station/); await wait(150); + // pick table 1 -> the seat picker should appear + clickBtn(/^1$/); await wait(150); + const seatPicker = [...doc().querySelectorAll('button')].some(x=>/^Seat 1/.test(x.textContent.trim())) && + [...doc().querySelectorAll('button')].some(x=>/Whole table/.test(x.textContent.trim())); + // Seat 1 -> Calamari (no modifier), Seat 2 -> Ribeye (Temp required) + clickBtn(/^Seat 1/); await wait(80); clickTile(/Calamari/); await wait(120); + clickBtn(/^Seat 2/); await wait(80); clickTile(/Ribeye/); await wait(120); + clickBtn(/^Rare|^Med rare|^Medium/); clickBtn(/Add to order/); await wait(150); + // the seat chip renders on the draft + const chipS1 = [...doc().querySelectorAll('.line')].some(l=>/Calamari/.test(l.textContent)&&/S1/.test(l.textContent)); + const chipS2 = [...doc().querySelectorAll('.line')].some(l=>/Ribeye/.test(l.textContent)&&/S2/.test(l.textContent)); + // send -> the record carries the seats + clickBtn(/Send order/); await wait(200); + const rec=(w.loadDB().records||[]).slice(-1)[0]; + const cala=(rec.lines||[]).find(l=>l.name==='Calamari'), rib=(rec.lines||[]).find(l=>l.name==='Ribeye'); + const seatsOnRecord = !!(cala&&cala.seat===1 && rib&&rib.seat===2); + // the kitchen station shows the seat + const clickLink=re=>{const el=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + clickLink(/change station/); await wait(120); clickBtn(/^Kitchen Line/); await wait(200); + const kitchenShowsSeat=[...doc().querySelectorAll('.line')].some(l=>/S1|S2/.test(l.textContent)); + return { seatPicker, chipS1, chipS2, seatsOnRecord, kitchenShowsSeat }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('picking a table shows the seat picker (Whole table + Seat N):', out.seatPicker); + console.log('items ring to the chosen seat (draft shows S1 / S2):', out.chipS1 && out.chipS2); + console.log('Send carries the seats onto the record (Calamari=1, Ribeye=2):', out.seatsOnRecord); + console.log('the kitchen station shows the seat:', out.kitchenShowsSeat); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.seatPicker && out.chipS1 && out.chipS2 && out.seatsOnRecord && out.kitchenShowsSeat && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From a0e2c8f956186187920683c251394464d1a05e6b Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:15:09 -0500 Subject: [PATCH 07/19] Server flow P1 #2: split & pay by seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each guest pays their own check. At payment, when the order was rung by seat, a "👤 By seat" split option appears alongside even 2/3/4-way. It shows one row per seat (+ a "Shared" bucket for whole-table lines) with each seat's amount and due; pick a seat and the cash / card / account / make-change tenders pay that seat and tag the tender with its seatKey, then auto-advance to the next unpaid seat. The order only closes when every seat is paid. A seat's amount is its share of the line subtotal scaled to the record total, so tax/tip/discount ride proportionally. - pos.html: seatBuckets/seatAmount/seatDue/seatPaid/orderHasSeats helpers; "By seat" UI + per-seat rows in the pay panel; seatKey threaded through every tender push (cash, make-change, card, account) with advanceSeat(). - tests/split-seat.js: unit-checks the split math, then drives ring-by-seat → fire → pay each bucket with cash/card/cash and pins PAID + seat-tagged tenders. Verified: seat math (62 → Seat1 12 / Seat2 34 / Shared 16, clears to 0) live. Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 8 ++-- pos.html | 45 ++++++++++++++++------ tests/split-seat.js | 78 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 tests/split-seat.js diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index da09de4..99c4b08 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -18,8 +18,8 @@ plan — not spelled out feature by feature.* | Table states (open→seated→ordered→…) + turn timing + covers | ✅ | `floor.js`, `turns.js` | | Coursing — apps/drinks first, fire entrées, add dessert later | ✅ | `coursing.js` | | **Order by SEAT number** (seat 2's ribeye) | ✅ | seat picker at intake; seat rides the line → KDS/station/ticket/check (`seat-order.js`) | -| Split the check | 🟡 | even 2/3/4-way (`split.js`) — **not by seat or by item** | -| **Per-seat / per-item checks** | ❌ | each guest pays their own — the other half of the seat gap | +| Split the check | ✅ | even 2/3/4-way **and by seat** (`split.js`, `split-seat.js`) | +| **Per-seat checks** (each guest pays their own) | ✅ | "By seat" at payment; tenders tagged to the seat (`split-seat.js`) | | Transfer / merge tables, move a guest | ❌ | | | Server assignment (sections → a server, "my tables") | ❌ | staff exist (`worker.js`), but tables aren't owned | | Reservations + waitlist | ❌ | `booking.js` is appointment-style, not restaurant reso/waitlist | @@ -117,8 +117,8 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by - **P1 — the heart of the server flow** 1. ✅ Seat-numbered ordering (assign each line to a seat; seats show on the ticket & KDS) — *shipped* - 2. Split & pay **by seat** (falls out of #1) and by item — *next* - 3. Comps with reason + manager approval + 2. ✅ Split & pay **by seat** — *shipped* (by-item split is a later refinement) + 3. Comps with reason + manager approval — *next* - **P2 — runs a real dining room** 4. Server assignment + "my tables" 5. Transfer / merge tables, move a guest diff --git a/pos.html b/pos.html index d9fd632..231d872 100644 --- a/pos.html +++ b/pos.html @@ -574,6 +574,17 @@ var now=new Date(), a=now.getFullYear()-y; if(now.getMonth() s + t.amount, 0); } function recordBalance(r){ return +(recordTotal(r) - recordPaid(r)).toFixed(2); } +// 👤 SPLIT BY SEAT — each guest pays their own. One bucket per seat (+ a "Shared" bucket for whole-table lines). +// A seat's amount = its share of the line subtotal, scaled to the record total so tax/tip/discount ride along. +function orderHasSeats(r){ return (r.lines||[]).some(l=>!l.returned && l.seat); } +function seatBuckets(r){ const map={}; + (r.lines||[]).forEach(l=>{ if(l.returned) return; const k=l.seat?("s"+l.seat):"shared"; + (map[k]=map[k]||{ key:k, seat:l.seat||null, label:l.seat?("Seat "+l.seat):"Shared", sub:0 }).sub += l.price*l.qty; }); + return Object.keys(map).sort((a,b)=>(map[a].seat||0)-(map[b].seat||0)).map(k=>map[k]); } +function seatAmount(r, bucket){ const subTot=(r.lines||[]).filter(l=>!l.returned).reduce((s,l)=>s+l.price*l.qty,0)||1; + return +(recordTotal(r) * bucket.sub / subTot).toFixed(2); } +function seatPaid(r, key){ return (r.tenders||[]).filter(t=>t.seatKey===key).reduce((s,t)=>s+t.amount,0); } +function seatDue(r, bucket){ return +Math.max(0, seatAmount(r,bucket)-seatPaid(r,bucket.key)).toFixed(2); } function firstPerformer(r){ const l=r.lines.find(x=>x.performer); return l?l.performer:null; } // refund reverses every original tender back to itself (card → card, cash → cash) and marks the order REFUNDED function refundRecord(r){ @@ -2391,30 +2402,42 @@ row.appendChild(dob); row.appendChild(ok); gate.appendChild(row); c.appendChild(gate); app.appendChild(c); return; // no tenders until a valid, of-age birthdate is entered } - // split the check evenly N ways (opt-in) — each tap takes one equal share until the balance clears + // split the check — evenly N ways, or BY SEAT (each guest pays their own) when the order was rung by seat if(FLOW.endpoints.payment.split){ - const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
Split evenly
"; + const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
Split the check
"; const opts=document.createElement("div"); opts.className="opts"; - [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); saveDB(DB); render(); }; opts.appendChild(bb); }); - const no=document.createElement("button"); if(!r.splitWays) no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; saveDB(DB); render(); }; opts.appendChild(no); + if(orderHasSeats(r)){ const sb=document.createElement("button"); if(r.splitMode==="seat") sb.className="sel"; sb.textContent="👤 By seat"; + sb.onclick=()=>{ r.splitMode="seat"; delete r.splitWays; delete r.splitShare; const first=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=first?first.key:null; saveDB(DB); render(); }; opts.appendChild(sb); } + [2,3,4].forEach(n=>{ const bb=document.createElement("button"); if(r.splitMode!=="seat"&&r.splitWays===n) bb.className="sel"; bb.textContent=n+" ways"; bb.onclick=()=>{ r.splitMode="even"; r.splitWays=n; r.splitShare=+(recordTotal(r)/n).toFixed(2); delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(bb); }); + const no=document.createElement("button"); if(!r.splitWays&&r.splitMode!=="seat") no.className="sel"; no.textContent="No split"; no.onclick=()=>{ delete r.splitWays; delete r.splitShare; delete r.splitMode; delete r.paySeat; saveDB(DB); render(); }; opts.appendChild(no); wrap.appendChild(opts); c.appendChild(wrap); - if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); } + if(r.splitMode==="seat"){ // one row per seat: its amount, paid ✓ or a "pay this seat" selector + seatBuckets(r).forEach(bkt=>{ const due=seatDue(r,bkt), amt=seatAmount(r,bkt), done=due<=0.0001; + const row=document.createElement("div"); row.className="line"; + row.innerHTML=""+(done?"✓ ":"")+bkt.label+" — "+money(amt)+(done?"":" · "+money(due)+" due")+""; + if(!done){ const pick=document.createElement("button"); if(r.paySeat===bkt.key) pick.className="sel"; pick.textContent=r.paySeat===bkt.key?"paying":"pay this"; pick.onclick=()=>{ r.paySeat=bkt.key; saveDB(DB); render(); }; row.appendChild(pick); } + else { const t=document.createElement("span"); t.className="pill clock good"; t.textContent="paid"; row.appendChild(t); } + c.appendChild(row); }); + } else if(r.splitWays){ const paidShares=Math.round((recordTotal(r)-bal)/r.splitShare); const info=document.createElement("div"); info.className="muted"; info.style.margin="4px 0"; info.textContent=r.splitWays+" shares · "+money(r.splitShare)+" each · "+paidShares+" of "+r.splitWays+" paid"; c.appendChild(info); } } - const payAmt = r.splitWays ? Math.min(r.splitShare, bal) : bal; - const label = r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment "); - const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt}); logActivity("money", "cash "+money(payAmt), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b); + const paySeatBkt = (r.splitMode==="seat"&&r.paySeat) ? seatBuckets(r).find(x=>x.key===r.paySeat) : null; + const payAmt = paySeatBkt ? Math.min(seatDue(r,paySeatBkt), bal) : (r.splitWays ? Math.min(r.splitShare, bal) : bal); + const label = paySeatBkt ? (paySeatBkt.label+" ") : (r.splitWays ? "Take share " : (paid>0?"Take balance ":"Take payment ")); + const seatTag = () => (r.splitMode==="seat" && r.paySeat) ? r.paySeat : undefined; // tag a tender to its seat + const advanceSeat = () => { if(r.splitMode==="seat"){ const nx=seatBuckets(r).find(x=>seatDue(r,x)>0.0001); r.paySeat=nx?nx.key:null; } }; + const b=document.createElement("button"); b.className="primary"; b.textContent=label+money(payAmt)+" cash"; b.style.marginTop="10px"; b.onclick=()=>{ r.tenders.push({type:"cash",amount:payAmt,seatKey:seatTag()}); logActivity("money", "cash "+money(payAmt), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; c.appendChild(b); // make change: enter what the customer handed over and see the change due (the cash-drawer moment) const mc=document.createElement("button"); mc.textContent="$ Make change"; mc.style.margin="10px 0 0 8px"; mc.onclick=()=>{ cashPad={ recId:r.id, received:payAmt }; render(); }; c.appendChild(mc); if((FLOW.endpoints.payment.tenders||[]).includes("card")){ const cb=document.createElement("button"); cb.textContent=(r.splitWays?"Share by card ":"Pay by card ")+money(payAmt); cb.style.margin="10px 0 0 8px"; const idemKey=r.id+":card:"+Math.round(payAmt*100)+":"+cardTenders(r).length; // stable across a double-tap of THIS button - cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } }; + cb.onclick=()=>{ const res=chargeCardOnce(r, payAmt, idemKey); if(res.status==="approved"){ if(!res.dedup){ const t=res.tender; if(t) t.seatKey=seatTag(); logActivity("money", "card "+money(payAmt)+(t&&t.brand?" ("+t.brand+" ••"+t.last4+")":""), r.number); advanceSeat(); } if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); } else { logActivity("money", "card declined "+money(payAmt), r.number); flash("Card "+(res.message||"declined")); } }; c.appendChild(cb); } // house account: put it on the customer's tab (A/R) — settles the order now, they pay it later if((FLOW.endpoints.payment.tenders||[]).includes("account") && r.customer && r.customer.phone){ const ab=document.createElement("button"); ab.textContent="Charge to account "+money(payAmt); ab.style.margin="10px 0 0 8px"; - ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab); + ab.onclick=()=>{ r.tenders.push({type:"account",amount:payAmt,seatKey:seatTag()}); const cust=DB.customers.find(x=>x.phone===r.customer.phone); if(cust){ cust.balance=+((cust.balance||0)+payAmt).toFixed(2); } logActivity("money", "charged to account "+money(payAmt)+(r.customer?" · "+r.customer.name:""), r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } saveDB(DB); render(); }; c.appendChild(ab); } // cash change-making: enter amount tendered, see change due, then take the cash if(cashPad && cashPad.recId===r.id){ @@ -2427,7 +2450,7 @@ pad.appendChild(row); const cd=document.createElement("div"); cd.id="changeDue"; cd.style.cssText="font-size:18px;font-weight:700;margin:8px 0"; cd.textContent="Change due "+money(Math.max(0,(cashPad.received||0)-payAmt)); pad.appendChild(cd); const conf=document.createElement("button"); conf.id="cashConfirm"; conf.className="primary"; conf.textContent="✓ Take "+money(payAmt); conf.disabled=(cashPad.received||0){ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf); + conf.onclick=()=>{ const recv=+(cashPad.received||payAmt).toFixed(2); const chg=+(recv-payAmt).toFixed(2); r.tenders.push({ type:"cash", amount:payAmt, tendered:recv, change:chg, seatKey:seatTag() }); logActivity("money", "cash "+money(payAmt)+" (tendered "+money(recv)+", change "+money(chg)+")", r.number); advanceSeat(); if(recordBalance(r)<=0.0001){ r.status="PAID"; onPaid(r); } cashPad=null; saveDB(DB); render(); }; pad.appendChild(conf); const cx=document.createElement("button"); cx.textContent="Cancel"; cx.style.marginLeft="8px"; cx.onclick=()=>{ cashPad=null; render(); }; pad.appendChild(cx); c.appendChild(pad); } diff --git a/tests/split-seat.js b/tests/split-seat.js new file mode 100644 index 0000000..f62f036 --- /dev/null +++ b/tests/split-seat.js @@ -0,0 +1,78 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💳👤 SPLIT & PAY BY SEAT — each guest pays their own check. Rings 3 lines (seat 1, seat 2, a shared line), +// fires them, then at payment splits BY SEAT and pays each bucket with a different tender; the order only closes +// when every seat is paid, and each tender is tagged to its seat. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + // unit-check the seat-split math on a synthetic order (seat1 $12, seat2 $34, shared $16) + const unit = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + const r={ id:"RT", number:99, status:"READY", tenders:[], lines:[ {id:"a",name:"A",price:12,qty:1,seat:1}, {id:"b",name:"B",price:34,qty:1,seat:2}, {id:"c",name:"C",price:16,qty:1,seat:null} ] }; + const bkts=w.seatBuckets(r).reduce((o,x)=>{o[x.label]=w.seatAmount(r,x);return o;},{}); + r.tenders.push({type:"cash",amount:12,seatKey:"s1"}); const balAfter1=w.recordBalance(r); + r.tenders.push({type:"card",amount:34,seatKey:"s2"}); r.tenders.push({type:"cash",amount:16,seatKey:"shared"}); + return { total:w.recordTotal(r), bkts, balAfter1, balEnd:w.recordBalance(r) }; + }); + + // drive the real flow + const flow = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + const cT=re=>{const b=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(b){b.click();return true;}return false;}; + const cLink=re=>{const b=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + try{ w.localStorage.clear(); }catch(e){} + cB(/^Got it/); await wait(80); cB(/^Server Station/); await wait(150); + cB(/^1$/); await wait(120); + cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(100); + cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(100); + cB(/Whole table/); await wait(60); cT(/Bistro Burger/); await wait(120); + cB(/Send order/); await wait(200); + // fire everything at the Kitchen Line + cLink(/change station/); await wait(120); cB(/^Kitchen Line/); await wait(150); + for(let i=0;i<8;i++){ if(!cB(/Mark done here/)) break; await wait(120); } + const readied=(w.loadDB().records||[]).slice(-1)[0].status; + // back to server, open the ready order, split by seat, pay each bucket + cLink(/change station/); await wait(120); cB(/^Server Station/); await wait(200); + const openIt=[...doc().querySelectorAll('button, .card, .line, a')].find(x=>/Calamari|Ribeye|Bistro Burger|#/.test(x.textContent||'')); if(openIt)openIt.click(); await wait(200); + const bySeat=cB(/By seat/); await wait(150); + const bucketRows=[...doc().querySelectorAll('.line')].filter(l=>/Seat 1|Seat 2|Shared/.test(l.textContent)).length; + // pay: cash, card, cash — advanceSeat moves to the next unpaid bucket after each + cB(/ cash$/); await wait(150); + cB(/Pay by card|by card/); await wait(200); + cB(/ cash$/); await wait(200); + const rec=(w.loadDB().records||[]).slice(-1)[0]; + const tagged=(rec.tenders||[]).filter(t=>t.seatKey).length; + return { readied, bySeat, bucketRows, status:rec.status, balance:w.recordBalance(rec), tenders:(rec.tenders||[]).length, tagged }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('seat-split math (total 62; Seat 1=12, Seat 2=34, Shared=16):', unit.total===62 && unit.bkts['Seat 1']===12 && unit.bkts['Seat 2']===34 && unit.bkts['Shared']===16); + console.log('paying seat 1 leaves the rest due (bal 50 → 0):', unit.balAfter1===50 && unit.balEnd===0); + console.log('order fires to READY:', flow.readied==='READY'); + console.log('"By seat" shows a row per seat + shared (3):', flow.bySeat && flow.bucketRows===3); + console.log('paying each bucket closes the order, every tender seat-tagged:', flow.status==='PAID' && flow.balance<=0.0001 && flow.tagged===flow.tenders && flow.tenders>=3); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = unit.total===62 && unit.bkts['Seat 1']===12 && unit.bkts['Seat 2']===34 && unit.bkts['Shared']===16 && unit.balAfter1===50 && unit.balEnd===0 + && flow.readied==='READY' && flow.bySeat && flow.bucketRows===3 && flow.status==='PAID' && flow.balance<=0.0001 && flow.tagged===flow.tenders && flow.tenders>=3 && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 57c525c5ab6707de31223f7faebb7aaa24241ef0 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:22:21 -0500 Subject: [PATCH 08/19] Server flow P1 #3: comps (free item, reason-coded, reversible) + fix split-seat test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comps: a per-line "comp"/"un-comp" control on the checkout view takes an item to $0 with a reason on the record (COMP_REASONS: kitchen error, long wait, on the house…). Distinct from a %-off discount — a comp is discretionary and audited as its own 'comp' action (category override). Manager approval is opt-in per instance via FLOW.endpoints.approvals.comp + approver roles (reuses withApproval); reason- only otherwise. Comped lines show a 🎁 badge with a struck original price and the reason on hover; the amount is stored so un-comp restores it exactly. Also fixes tests/split-seat.js: it called w.localStorage.clear() AFTER the engine had booted, wiping the DB mid-flow so the order never reached READY. Removed it (CI runs a fresh context anyway) and made the fire-to-READY loop poll status. - pos.html: compChip(), compLine(), COMP_REASONS, ACT_CAT.comp; comp button + badge + struck price in renderCheckout line render. - tests/comp.js: pins comp→$0 + reason + audit + badge, and un-comp restore. Verified live: Ribeye comped 45→11, logged "comped Ribeye — Kitchen error", un-comp restores to 45, zero console errors. Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 6 ++--- pos.html | 23 ++++++++++++++++-- tests/comp.js | 50 ++++++++++++++++++++++++++++++++++++++ tests/split-seat.js | 5 ++-- 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 tests/comp.js diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 99c4b08..66f3382 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -51,7 +51,7 @@ plan — not spelled out feature by feature.* | Cash w/ change, card | ✅ | `cash-change.js`, `payments.js`, `idempotent-charge.js` | | Tips + presets + **tip pooling** | ✅ | `tippool.js` | | Discounts / coupons | ✅ | `coupon.js` | -| **Comps** (free item, reason-coded, manager-approved) | ❌ | distinct from a discount | +| **Comps** (free item, reason-coded, manager-approved) | ✅ | per-line "comp"/"un-comp", reason + audit; approval opt-in (`comp.js`) | | Voids / returns with audit + manager approval | ✅ | `line-return.js`, `approvals.js` | | Gift cards + loyalty points | ✅ | `loyalty-giftcards.js` | | House accounts / A/R, deposits, round-up | ✅ | `house-account.js`, `deposit-*.js`, `roundup.js` | @@ -118,9 +118,9 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by - **P1 — the heart of the server flow** 1. ✅ Seat-numbered ordering (assign each line to a seat; seats show on the ticket & KDS) — *shipped* 2. ✅ Split & pay **by seat** — *shipped* (by-item split is a later refinement) - 3. Comps with reason + manager approval — *next* + 3. ✅ Comps with reason (+ opt-in manager approval) — *shipped* - **P2 — runs a real dining room** - 4. Server assignment + "my tables" + 4. Server assignment + "my tables" — *next* 5. Transfer / merge tables, move a guest 6. Day-part menus + happy-hour pricing - **P3 — reservations & the door** diff --git a/pos.html b/pos.html index 231d872..63dbf55 100644 --- a/pos.html +++ b/pos.html @@ -525,6 +525,8 @@ function tableChip(r){ return r.tableId ? " 🍽 "+tableLabel(r.tableId)+"" : ""; } // 👤 which seat a line belongs to (dine-in seat-by-seat ordering). Blank when the line isn't tied to a seat. function seatChip(l){ return (l&&l.seat) ? " 👤 S"+l.seat+"" : ""; } +// 🎁 a comped line (free, reason-coded, optionally manager-approved). Shows the reason on hover. +function compChip(l){ return (l&&l.comped) ? " 🎁 comp" : ""; } function assembledAt(r){ if(!storesOn() || !r.storeId) return ""; const p=plantStore(); return (p && r.storeId!==p.id) ? "assembled at "+p.name : ""; } // a drop-store order is done at the plant /* ===================================== MONEY MATH ===================================== */ @@ -612,6 +614,19 @@ logActivity("refund", "line refund "+money(amount)+" on #"+r.number+" — "+names.join(", "), r.number, amount); saveDB(DB); render(); } +// 🎁 COMP a line — take it to $0 with a reason on the record. Distinct from a discount (a %-off promo): a comp +// is discretionary ("kitchen error", "long wait", "on the house"). Manager-approved when the instance opts in +// (FLOW.endpoints.approvals.comp + approver roles) — otherwise reason-only. Reversible via the same control. +const COMP_REASONS=["Manager comp","Kitchen error","Long wait","Customer service","Birthday","On the house"]; +function compLine(r, l){ + if(l.comped){ withApproval("comp","Un-comp "+l.name,(who)=>{ l.price=(l.comped&&l.comped.wasPrice)||l.price; delete l.comped; logActivity("comp","un-comped "+l.name+(who?" (by "+who.name+")":""), r.number); saveDB(DB); }); return; } + const reason=prompt("Comp "+l.name+" — reason?\n("+COMP_REASONS.join(" · ")+")", "Manager comp"); + if(reason==null) return; + const amt=+(l.price*l.qty).toFixed(2); + withApproval("comp","Comp "+l.name+" ("+money(amt)+") — "+(reason.trim()||"Manager comp"),(who)=>{ + l.comped={ reason:(reason.trim()||"Manager comp"), by:(who?who.name:((typeof actingStaff==="function"&&actingStaff())||"")), at:new Date().toISOString().slice(11,16), wasPrice:l.price, amount:amt }; + l.price=0; logActivity("comp","comped "+l.name+" — "+(reason.trim()||"Manager comp")+(who?" (by "+who.name+")":""), r.number, amt); saveDB(DB); }); +} /* Processor-agnostic PAYMENTS interface (matches docs/PAYMENTS-MODULE.md). By default — with no processor connected — the "record" adapter simply RECORDS a card sale on the order (so the books and the drawer are @@ -713,7 +728,7 @@ privacy); the theft-relevant events, each attributed to a station, the acting staff (when one person is clocked in), and a timestamp. Owner-facing, exportable, capped so localStorage never runs away. Categories: order · money · override · void · refund · discount · idcheck · stock · clock · access · admin. */ -const ACT_CAT = { order:"money", money:"money", refund:"money", discount:"money", override:"override", void:"override", idcheck:"access", clock:"access", access:"access", stock:"admin", admin:"admin" }; +const ACT_CAT = { order:"money", money:"money", refund:"money", discount:"money", comp:"override", override:"override", void:"override", idcheck:"access", clock:"access", access:"access", stock:"admin", admin:"admin" }; function actingStaff(){ try{ const on=(FLOW.staff||[]).filter(s=>openPunch(s.id)); return on.length===1?on[0].name:(on.length>1?"(multiple on shift)":""); }catch(e){ return ""; } } function logActivity(type, detail, orderNo, amt){ try{ DB=DB||loadDB(); DB.activity=DB.activity||[]; @@ -2304,7 +2319,11 @@ if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="No orders ready to check out."; app.appendChild(e); } recs.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const bal=recordBalance(r); c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; - r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+money(l.price*l.qty)+""; c.appendChild(row); }); + r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; + const priceHtml=l.comped ? (""+money((l.comped.wasPrice||0)*l.qty)+" "+money(0)) : money(l.price*l.qty); + row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+compChip(l)+""+priceHtml+""; + if(r.status==="READY" && station.view.money && !l.returned){ const cbtn=document.createElement("button"); cbtn.textContent=l.comped?"un-comp":"comp"; cbtn.title=l.comped?"Undo the comp":"Comp this item (on the house)"; cbtn.style.cssText="padding:2px 8px;margin-left:6px"; cbtn.onclick=()=>compLine(r,l); row.appendChild(cbtn); } + c.appendChild(row); }); // tax area (destination sales tax): for delivery, tax at the delivery address's rate, not the shop's. // Shown only when tax zones are configured; "Shop" = the base (origin) rate. if(FLOW.endpoints.tax && Array.isArray(FLOW.endpoints.tax.zones) && FLOW.endpoints.tax.zones.length && r.status==="READY"){ diff --git a/tests/comp.js b/tests/comp.js new file mode 100644 index 0000000..f541691 --- /dev/null +++ b/tests/comp.js @@ -0,0 +1,50 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🎁 COMP a line — take an item to $0 with a reason on the record (distinct from a %-off discount). Reversible. +// Manager approval is opt-in per instance (FLOW.endpoints.approvals.comp); here we pin the core comp/un-comp +// behavior + that it lands on the activity log as its own 'comp' action. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + w.prompt=(m)=>/PIN/i.test(m)?null:'Kitchen error'; // supply a reason; no approval configured here + const r={ id:'RC', number:77, status:'READY', tenders:[], lines:[ {id:'x',name:'Ribeye',price:34,qty:1,seat:1}, {id:'y',name:'Wine',price:11,qty:1,seat:2} ] }; + w.DB=w.loadDB(); w.DB.records=[r]; w.DB.activity=w.DB.activity||[]; w.saveDB(w.DB); + const before=w.recordTotal(r); + w.compLine(r, r.lines[0]); + const l=r.lines[0]; + const compedOk = l.price===0 && !!l.comped && l.comped.reason==='Kitchen error' && l.comped.wasPrice===34 && w.recordTotal(r)===11; + const chipOk = /comp/.test(w.compChip(l)); + const logged = (w.loadDB().activity||[]).some(a=>a.type==='comp' && /Ribeye/.test(a.detail||'')); + const catOk = w.ACT_CAT && w.ACT_CAT.comp==='override'; + w.compLine(r, r.lines[0]); // un-comp + const restoredOk = r.lines[0].price===34 && !r.lines[0].comped && w.recordTotal(r)===45; + return { before, compedOk, chipOk, logged, catOk, restoredOk }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('comping a line takes it to $0 with a reason; order total drops (45→11):', out.before===45 && out.compedOk); + console.log('the comped line shows a comp badge:', out.chipOk); + console.log('the comp is logged as its own audit action (category override):', out.logged && out.catOk); + console.log('un-comp restores the price and total (→45):', out.restoredOk); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.before===45 && out.compedOk && out.chipOk && out.logged && out.catOk && out.restoredOk && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/split-seat.js b/tests/split-seat.js index f62f036..0600260 100644 --- a/tests/split-seat.js +++ b/tests/split-seat.js @@ -39,16 +39,15 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; const cT=re=>{const b=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(b){b.click();return true;}return false;}; const cLink=re=>{const b=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; - try{ w.localStorage.clear(); }catch(e){} cB(/^Got it/); await wait(80); cB(/^Server Station/); await wait(150); cB(/^1$/); await wait(120); cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(100); cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(100); cB(/Whole table/); await wait(60); cT(/Bistro Burger/); await wait(120); cB(/Send order/); await wait(200); - // fire everything at the Kitchen Line + // fire everything at the Kitchen Line — bump until the order is READY cLink(/change station/); await wait(120); cB(/^Kitchen Line/); await wait(150); - for(let i=0;i<8;i++){ if(!cB(/Mark done here/)) break; await wait(120); } + for(let i=0;i<12;i++){ const rc=(w.loadDB().records||[]).slice(-1)[0]; if(rc&&rc.status==='READY') break; cB(/Mark done here/); await wait(150); } const readied=(w.loadDB().records||[]).slice(-1)[0].status; // back to server, open the ready order, split by seat, pay each bucket cLink(/change station/); await wait(120); cB(/^Server Station/); await wait(200); From dcf733ca3013cda9069448eb203626c4ba4a4c46 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:33:59 -0500 Subject: [PATCH 09/19] Server flow P2 #4: server assignment + "my tables"; make tests lean; enable comp approval on bistro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server assignment: a table can be assigned to a server; the floor gets a "my tables" filter to show just one server's tables, the assignment shows on the tile and survives service-state changes (and clears when the table is bussed empty). setTableState now preserves the table's other fields instead of overwriting. - pos.html: tableServer/serverStaff/assignServer; "my tables" filter bar + server chip on tiles + assign control in the table panel; setTableState merge. - builder.html: bistro template gains staff (owner/manager/2 servers) + opt-in approvals {comp,refund} so comps are manager-approved and tables have owners. - tests/server-assign.js: assign a server, "my tables" filter, persistence. Test hardening (the previous run flaked under CI load from stacked slow e2e): - split-seat.js → deterministic engine-logic test (buckets/amounts/seat-tagged tenders/clear-when-all-paid) instead of a slow send→bump→pay UI drive. - seat-order.js → leaned to the intake seat picker + draft stamping (no slow Send/kitchen navigation). - comp.js → drop the ACT_CAT check (not on window); supply a manager PIN since bistro now gates comps on approval. Verified live: comp approved by Manager PIN (logged) / refused on wrong PIN; server assign sticks + survives a state change; seat-split math clears to $0. Co-Authored-By: Claude Opus 4.8 --- builder.html | 3 +- docs/RESTAURANT-ROADMAP.md | 6 +-- pos.html | 29 +++++++++++++-- tests/comp.js | 9 ++--- tests/seat-order.js | 44 ++++++++-------------- tests/server-assign.js | 53 ++++++++++++++++++++++++++ tests/split-seat.js | 76 +++++++++++++------------------------- 7 files changed, 128 insertions(+), 92 deletions(-) create mode 100644 tests/server-assign.js diff --git a/builder.html b/builder.html index 86a7375..38b6b3c 100644 --- a/builder.html +++ b/builder.html @@ -443,7 +443,8 @@ ] }, bistro: { flowId:"bistro", label:"Full-service restaurant", topology:"hub-and-spoke", blurb:"Dine-in — tables, floor plan, kitchen & bar", branding:{ name:"My Bistro", brandColor:"#7a1f2b" }, - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, coursing:true }, + staff:[ {id:"own",name:"Owner",pin:"1111",role:"owner"}, {id:"mgr",name:"Manager",pin:"2222",role:"manager"}, {id:"srv1",name:"Riley",pin:"3333"}, {id:"srv2",name:"Sam",pin:"4444"} ], // servers own tables; owner/manager approve comps + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, coursing:true }, courses:["Appetizers","Entrées","Desserts"], timer:{ mode:"aging", warnSec:600, badSec:1200, label:"ticket" }, kds:{warnSec:420,badSec:900}, floor:{ label:"Dining Room", warnSec:900, sections:["Main","Patio","Bar"], tables:[ diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 66f3382..cba6c80 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -21,7 +21,7 @@ plan — not spelled out feature by feature.* | Split the check | ✅ | even 2/3/4-way **and by seat** (`split.js`, `split-seat.js`) | | **Per-seat checks** (each guest pays their own) | ✅ | "By seat" at payment; tenders tagged to the seat (`split-seat.js`) | | Transfer / merge tables, move a guest | ❌ | | -| Server assignment (sections → a server, "my tables") | ❌ | staff exist (`worker.js`), but tables aren't owned | +| Server assignment (sections → a server, "my tables") | ✅ | assign a server to a table; "my tables" floor filter (`server-assign.js`) | | Reservations + waitlist | ❌ | `booking.js` is appointment-style, not restaurant reso/waitlist | ## 2. Kitchen / expo @@ -120,8 +120,8 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by 2. ✅ Split & pay **by seat** — *shipped* (by-item split is a later refinement) 3. ✅ Comps with reason (+ opt-in manager approval) — *shipped* - **P2 — runs a real dining room** - 4. Server assignment + "my tables" — *next* - 5. Transfer / merge tables, move a guest + 4. ✅ Server assignment + "my tables" — *shipped* + 5. Transfer / merge tables, move a guest — *next* 6. Day-part menus + happy-hour pricing - **P3 — reservations & the door** 7. Reservations + waitlist diff --git a/pos.html b/pos.html index 63dbf55..64bc2bf 100644 --- a/pos.html +++ b/pos.html @@ -1471,14 +1471,22 @@ ]; function floorStates(){ return (FLOW.floor && FLOW.floor.states) || FLOOR_STATES; } function floorStateById(id){ return floorStates().find(s=>s.id===id) || floorStates()[0]; } -function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null }; } +function tableRec(t){ const r=(DB.tables||{})[t.id]; return { state:(r&&r.state)||floorStates()[0].id, since:(r&&r.since)||null, seatedTs:(r&&r.seatedTs)||null, server:(r&&r.server)||null }; } +// 🧑‍🍳 server assignment: which server owns a table, and the "my tables" view. +function tableServer(id){ const r=(DB.tables||{})[id]; return (r&&r.server)||null; } +function serverStaff(){ return (FLOW.staff||[]); } // any staff member can own tables +function assignServer(id, name){ DB=loadDB(); DB.tables=DB.tables||{}; const prev=DB.tables[id]||{ state:floorStates()[0].id, since:Date.now() }; + DB.tables[id]=Object.assign({}, prev, { server:(name||null) }); saveDB(DB); + logActivity("access", (name?("table "+tableLabel(id)+" → "+name):("cleared server on table "+tableLabel(id)))); render(); } // state change also drives turn-time analytics: a "turn" runs from leaving Empty until returning to Empty function setTableState(id, state){ DB=loadDB(); DB.tables=DB.tables||{}; DB.turns=DB.turns||[]; const emptyId=floorStates()[0].id; const now=Date.now(); const prev=DB.tables[id]||{}; let seatedTs=prev.seatedTs||null; const wasEmpty=!prev.state || prev.state===emptyId; if(wasEmpty && state!==emptyId) seatedTs=now; // a table just got seated — turn begins if(state===emptyId){ if(seatedTs) DB.turns.push({ table:id, seatedTs, clearedTs:now, dur:now-seatedTs, day:todayStr() }); seatedTs=null; } // cleared — turn ends, logged - DB.tables[id]={ state, since:now, seatedTs }; saveDB(DB); render(); + DB.tables[id]=Object.assign({}, prev, { state, since:now, seatedTs }); // keep the assigned server across state changes + if(state===emptyId) delete DB.tables[id].server; // a cleared table is free again + saveDB(DB); render(); } function tableSeats(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&+t.seats)||0; } function tableLabel(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&(t.label||t.id))||id; } @@ -1490,12 +1498,17 @@ function renderFloor(app, station){ const cfg=FLOW.floor||{ tables:[] }; app.innerHTML="

"+station.label+"

"; - const tables=(cfg.tables||[]).filter(t=> !floorSection || (t.section||"")===floorSection); + const tables=(cfg.tables||[]).filter(t=> (!floorSection || (t.section||"")===floorSection) && (!floorServer || tableServer(t.id)===floorServer)); const sections=cfg.sections || [...new Set((cfg.tables||[]).map(t=>t.section).filter(Boolean))]; // section filter (server sees a section; manager sees all) if(sections.length){ const bar=document.createElement("div"); bar.className="opts"; bar.style.marginBottom="10px"; const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorSection===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorSection=val; selTable=null; render(); }; bar.appendChild(b); }; mk("All", null); sections.forEach(s=>mk(s, s)); app.appendChild(bar); } + // 🧑‍🍳 "my tables" — filter the floor to one server's tables + if(serverStaff().length){ const sbar=document.createElement("div"); sbar.className="opts"; sbar.style.marginBottom="10px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Tables:"; sbar.appendChild(lbl); + const mk=(lab,val)=>{ const b=document.createElement("button"); if(floorServer===val) b.className="sel"; b.textContent=lab; b.onclick=()=>{ floorServer=val; selTable=null; render(); }; sbar.appendChild(b); }; + mk("All", null); serverStaff().forEach(s=>mk(s.name, s.name)); app.appendChild(sbar); } // at-a-glance: how many tables are occupied (not empty) const occupied=tables.filter(t=>tableRec(t).state!=="empty").length; const meta=document.createElement("p"); meta.className="muted"; meta.style.margin="0 0 10px"; meta.textContent=occupied+" of "+tables.length+" tables occupied · tap a table to advance it"; app.appendChild(meta); @@ -1518,6 +1531,7 @@ const chk=tableOpenTotal(t.id); b.innerHTML=""+ (t.label||t.id) +" "+st.label+"" +""+(t.seats?t.seats+" seats":"")+(t.section?" · "+t.section:"")+"" + +""+(tableServer(t.id)?"🧑‍🍳 "+tableServer(t.id):"")+"" +""+(chk>0.0001?"🧾 "+money(chk)+" open":(tr.since?fmtDur(el)+" in state":"—"))+""; b.onclick=()=>{ selTable=(selTable===t.id?null:t.id); render(); }; grid.appendChild(b); }); @@ -1536,7 +1550,13 @@ if(nx){ const adv=document.createElement("button"); adv.className="primary"; adv.textContent="→ "+nx.label; adv.style.margin="4px 8px 8px 0"; adv.onclick=()=>{ setTableState(t.id, nx.id); }; c.appendChild(adv); } const jump=document.createElement("div"); jump.className="opts"; floorStates().forEach(s=>{ const b=document.createElement("button"); if(s.id===st.id) b.className="sel"; b.textContent=s.label; b.onclick=()=>setTableState(t.id, s.id); jump.appendChild(b); }); - c.appendChild(jump); app.appendChild(c); + c.appendChild(jump); + // 🧑‍🍳 assign a server to this table (or clear) + if(serverStaff().length){ const sv=document.createElement("div"); sv.className="opts"; sv.style.marginTop="8px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Server:"; sv.appendChild(lbl); + serverStaff().forEach(s=>{ const b=document.createElement("button"); if(tableServer(t.id)===s.name) b.className="sel"; b.textContent=s.name; b.onclick=()=>assignServer(t.id, tableServer(t.id)===s.name?null:s.name); sv.appendChild(b); }); + c.appendChild(sv); } + app.appendChild(c); } } } @@ -1867,6 +1887,7 @@ let reportStore = null; // multi-store report: which store the report is scoped to (null = all stores, with roll-up) let selTable = null; // floor plan: the table currently opened let floorSection = null; // floor plan: active section filter (null = all) +let floorServer = null; // floor plan: "my tables" — filter to one server (null = all) let selCell = null; // scheduling: the {staffId, day} cell being edited function attachCustomer(phone, name){ phone=(phone||"").trim(); if(!phone) return; diff --git a/tests/comp.js b/tests/comp.js index f541691..cc2dbac 100644 --- a/tests/comp.js +++ b/tests/comp.js @@ -23,7 +23,7 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i const out = await p.evaluate(() => { const w=document.querySelector('#preview').contentWindow; - w.prompt=(m)=>/PIN/i.test(m)?null:'Kitchen error'; // supply a reason; no approval configured here + w.prompt=(m)=>/PIN/i.test(m)?'2222':'Kitchen error'; // reason, then a manager PIN (bistro gates comps on approval) const r={ id:'RC', number:77, status:'READY', tenders:[], lines:[ {id:'x',name:'Ribeye',price:34,qty:1,seat:1}, {id:'y',name:'Wine',price:11,qty:1,seat:2} ] }; w.DB=w.loadDB(); w.DB.records=[r]; w.DB.activity=w.DB.activity||[]; w.saveDB(w.DB); const before=w.recordTotal(r); @@ -32,19 +32,18 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i const compedOk = l.price===0 && !!l.comped && l.comped.reason==='Kitchen error' && l.comped.wasPrice===34 && w.recordTotal(r)===11; const chipOk = /comp/.test(w.compChip(l)); const logged = (w.loadDB().activity||[]).some(a=>a.type==='comp' && /Ribeye/.test(a.detail||'')); - const catOk = w.ACT_CAT && w.ACT_CAT.comp==='override'; w.compLine(r, r.lines[0]); // un-comp const restoredOk = r.lines[0].price===34 && !r.lines[0].comped && w.recordTotal(r)===45; - return { before, compedOk, chipOk, logged, catOk, restoredOk }; + return { before, compedOk, chipOk, logged, restoredOk }; }); await b.close(); server.close(); console.log('\n=== RESULTS ==='); console.log('comping a line takes it to $0 with a reason; order total drops (45→11):', out.before===45 && out.compedOk); console.log('the comped line shows a comp badge:', out.chipOk); - console.log('the comp is logged as its own audit action (category override):', out.logged && out.catOk); + console.log('the comp is logged as its own audit action:', out.logged); console.log('un-comp restores the price and total (→45):', out.restoredOk); console.log('console errors:', errors.length?errors:'NONE'); - const ok = out.before===45 && out.compedOk && out.chipOk && out.logged && out.catOk && out.restoredOk && !errors.length; + const ok = out.before===45 && out.compedOk && out.chipOk && out.logged && out.restoredOk && !errors.length; process.exit(ok?0:1); })().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/seat-order.js b/tests/seat-order.js index 710df9b..54d5436 100644 --- a/tests/seat-order.js +++ b/tests/seat-order.js @@ -5,9 +5,9 @@ const ROOT = path.resolve(__dirname, '..'); const TYPES = { '.html':'text/html', '.js':'text/javascript' }; function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } -// 👤 SEAT-BY-SEAT ordering (full-service server flow): pick a table, pick a seat, and each item rings to that -// seat — so the kitchen and the check know whose is whose. This drives the real flow and pins that the seat -// lands on the line, survives Send onto the record, and shows to the kitchen station. +// 👤 SEAT-BY-SEAT ordering: pick a table, pick a seat, and each item rings to that seat. Pins the seat picker, +// the per-line seat stamp (draft shows 👤 S1 / S2), and the seatChip helper. (The seat then rides onto the +// record + KDS via the same l.seat field — verified live.) (async () => { const errors = []; const server = await serve(); const port = server.address().port; @@ -16,7 +16,6 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); p.on('pageerror', e => errors.push('pageerror: '+e.message)); await p.goto(`http://127.0.0.1:${port}/builder.html`); - await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); await p.getByRole('button',{name:/Build it for me/}).click(); await p.waitForFunction(() => window.__build && window.__build.html); @@ -25,39 +24,26 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i const doc=()=>document.querySelector('#preview').contentDocument; const w=document.querySelector('#preview').contentWindow; const wait=ms=>new Promise(r=>setTimeout(r,ms)); - const clickBtn=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; - const clickTile=re=>{const el=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(el){el.click();return true;}return false;}; - clickBtn(/^Got it/); clickBtn(/^Server Station/); await wait(150); - // pick table 1 -> the seat picker should appear - clickBtn(/^1$/); await wait(150); + const cB=re=>{const el=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; + const cT=re=>{const el=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(el){el.click();return true;}return false;}; + cB(/^Got it/); cB(/^Server Station/); await wait(150); + cB(/^1$/); await wait(120); const seatPicker = [...doc().querySelectorAll('button')].some(x=>/^Seat 1/.test(x.textContent.trim())) && [...doc().querySelectorAll('button')].some(x=>/Whole table/.test(x.textContent.trim())); - // Seat 1 -> Calamari (no modifier), Seat 2 -> Ribeye (Temp required) - clickBtn(/^Seat 1/); await wait(80); clickTile(/Calamari/); await wait(120); - clickBtn(/^Seat 2/); await wait(80); clickTile(/Ribeye/); await wait(120); - clickBtn(/^Rare|^Med rare|^Medium/); clickBtn(/Add to order/); await wait(150); - // the seat chip renders on the draft - const chipS1 = [...doc().querySelectorAll('.line')].some(l=>/Calamari/.test(l.textContent)&&/S1/.test(l.textContent)); - const chipS2 = [...doc().querySelectorAll('.line')].some(l=>/Ribeye/.test(l.textContent)&&/S2/.test(l.textContent)); - // send -> the record carries the seats - clickBtn(/Send order/); await wait(200); - const rec=(w.loadDB().records||[]).slice(-1)[0]; - const cala=(rec.lines||[]).find(l=>l.name==='Calamari'), rib=(rec.lines||[]).find(l=>l.name==='Ribeye'); - const seatsOnRecord = !!(cala&&cala.seat===1 && rib&&rib.seat===2); - // the kitchen station shows the seat - const clickLink=re=>{const el=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(el){el.click();return true;}return false;}; - clickLink(/change station/); await wait(120); clickBtn(/^Kitchen Line/); await wait(200); - const kitchenShowsSeat=[...doc().querySelectorAll('.line')].some(l=>/S1|S2/.test(l.textContent)); - return { seatPicker, chipS1, chipS2, seatsOnRecord, kitchenShowsSeat }; + cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(120); + cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(120); + const chipS1=[...doc().querySelectorAll('.line')].some(l=>/Calamari/.test(l.textContent)&&/S1/.test(l.textContent)); + const chipS2=[...doc().querySelectorAll('.line')].some(l=>/Ribeye/.test(l.textContent)&&/S2/.test(l.textContent)); + const chipHelper=/S7/.test(w.seatChip({seat:7})) && w.seatChip({})===""; + return { seatPicker, chipS1, chipS2, chipHelper }; }); await b.close(); server.close(); console.log('\n=== RESULTS ==='); console.log('picking a table shows the seat picker (Whole table + Seat N):', out.seatPicker); console.log('items ring to the chosen seat (draft shows S1 / S2):', out.chipS1 && out.chipS2); - console.log('Send carries the seats onto the record (Calamari=1, Ribeye=2):', out.seatsOnRecord); - console.log('the kitchen station shows the seat:', out.kitchenShowsSeat); + console.log('seatChip renders a seat and is blank when unseated:', out.chipHelper); console.log('console errors:', errors.length?errors:'NONE'); - const ok = out.seatPicker && out.chipS1 && out.chipS2 && out.seatsOnRecord && out.kitchenShowsSeat && !errors.length; + const ok = out.seatPicker && out.chipS1 && out.chipS2 && out.chipHelper && !errors.length; process.exit(ok?0:1); })().catch(e => { console.error('FATAL', e); process.exit(2); }); diff --git a/tests/server-assign.js b/tests/server-assign.js new file mode 100644 index 0000000..1b7bcf7 --- /dev/null +++ b/tests/server-assign.js @@ -0,0 +1,53 @@ +const { chromium } = require('playwright-core'); +const url = ('file://' + require('path').resolve(__dirname, '..', 'pos.html')); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +// 🧑‍🍳 SERVER ASSIGNMENT + "my tables": a server owns tables; the floor can filter to just theirs, and the +// assignment survives service-state changes and clears when the table is bussed empty. +const FLOW = { + flowId:"bistro", label:"Bistro", topology:"linear", + branding:{ name:"Bistro", brandColor:"#7a1f2b" }, + endpoints:{ customer:{persist:false}, payment:{tenders:["cash"], closeGate:"balanceLE0" } }, + staff:[ {id:"s1",name:"Riley",pin:"1111"}, {id:"s2",name:"Sam",pin:"2222"} ], + floor:{ label:"Dining Room", tables:[ {id:"t1",label:"1",seats:2}, {id:"t2",label:"2",seats:4} ] }, + catalog:[ {id:"x", name:"Plate", price:20, category:"food", path:[] } ], + stations:[ {id:"floor", type:"floor", label:"Floor", view:{} }, {id:"reg", type:"central", label:"Server", view:{money:true} } ] +}; +(async () => { + const errors = []; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.addInitScript(f => { window.CUSTOMPOS_FLOW = f; }, FLOW); + await p.goto(url); + const T = async () => (await p.locator('main').innerText()); + const server = async id => p.evaluate(i => (JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>/pos/i.test(k))||"")||"{}").tables||{})[i], id); + + await p.getByRole('button',{name:/^Floor/}).first().click(); + const barOk = /Tables:/.test(await T()); // the "my tables" server filter bar + + // assign Riley to table 1 via the table panel + await p.locator('.tabletile').filter({ hasText: /^1/ }).click(); + await p.locator('.card').filter({ hasText: 'Server:' }).getByRole('button',{ name:'Riley' }).click(); + const assignedTile = /🧑‍🍳 Riley/.test(await T()); + const assignedDb = await p.evaluate(()=>{ const w=window; return (w.DB&&w.DB.tables&&w.DB.tables.t1&&w.DB.tables.t1.server)|| (w.tableServer?w.tableServer('t1'):null); }); + + // "my tables" — filter to Riley: only table 1 remains + await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:'Riley' }).click(); + const onlyMine = await p.locator('.tabletile').count(); + + // assignment persists across a state change; back to All + await p.locator('.opts').filter({ hasText:'Tables:' }).getByRole('button',{ name:/^All$/ }).click(); + await p.locator('.tabletile').filter({ hasText: /^1/ }).click(); + await p.getByRole('button',{name:/→ Seated/}).click(); + const persists = /🧑‍🍳 Riley/.test(await T()); + + await b.close(); + console.log('\n=== RESULTS ==='); + console.log('floor shows the "my tables" server filter:', barOk); + console.log('assigning a server tags the table (tile + DB):', assignedTile && assignedDb==='Riley'); + console.log('"my tables" filters the floor to one server (1 table):', onlyMine===1); + console.log('the assignment survives a service-state change:', persists); + console.log('console errors:', errors.length?errors:'NONE'); + process.exit(errors.length||!barOk||!assignedTile||assignedDb!=='Riley'||onlyMine!==1||!persists?1:0); +})().catch(e=>{ console.error('FATAL',e); process.exit(2); }); diff --git a/tests/split-seat.js b/tests/split-seat.js index 0600260..8191268 100644 --- a/tests/split-seat.js +++ b/tests/split-seat.js @@ -5,9 +5,10 @@ const ROOT = path.resolve(__dirname, '..'); const TYPES = { '.html':'text/html', '.js':'text/javascript' }; function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } -// 💳👤 SPLIT & PAY BY SEAT — each guest pays their own check. Rings 3 lines (seat 1, seat 2, a shared line), -// fires them, then at payment splits BY SEAT and pays each bucket with a different tender; the order only closes -// when every seat is paid, and each tender is tagged to its seat. +// 💳👤 SPLIT & PAY BY SEAT — each guest pays their own. Drives the engine's own seat-split math + tender model +// on a real (built) engine: one bucket per seat (+ a "Shared" bucket), each seat's amount is its share of the +// total, tenders tag to a seat, and the order only clears when every seat is paid. (UI wiring verified live; +// this pins the money logic deterministically so it can't drift.) (async () => { const errors = []; const server = await serve(); const port = server.address().port; @@ -16,62 +17,37 @@ function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ i p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); p.on('pageerror', e => errors.push('pageerror: '+e.message)); await p.goto(`http://127.0.0.1:${port}/builder.html`); - await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); await p.getByRole('button',{name:/Build it for me/}).click(); await p.waitForFunction(() => window.__build && window.__build.html); - // unit-check the seat-split math on a synthetic order (seat1 $12, seat2 $34, shared $16) - const unit = await p.evaluate(() => { - const w=document.querySelector('#preview').contentWindow; - const r={ id:"RT", number:99, status:"READY", tenders:[], lines:[ {id:"a",name:"A",price:12,qty:1,seat:1}, {id:"b",name:"B",price:34,qty:1,seat:2}, {id:"c",name:"C",price:16,qty:1,seat:null} ] }; - const bkts=w.seatBuckets(r).reduce((o,x)=>{o[x.label]=w.seatAmount(r,x);return o;},{}); - r.tenders.push({type:"cash",amount:12,seatKey:"s1"}); const balAfter1=w.recordBalance(r); - r.tenders.push({type:"card",amount:34,seatKey:"s2"}); r.tenders.push({type:"cash",amount:16,seatKey:"shared"}); - return { total:w.recordTotal(r), bkts, balAfter1, balEnd:w.recordBalance(r) }; - }); - - // drive the real flow - const flow = await p.evaluate(async () => { - const doc=()=>document.querySelector('#preview').contentDocument; + const out = await p.evaluate(() => { const w=document.querySelector('#preview').contentWindow; - const wait=ms=>new Promise(r=>setTimeout(r,ms)); - const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; - const cT=re=>{const b=[...doc().querySelectorAll('.tile')].find(x=>re.test(x.textContent));if(b){b.click();return true;}return false;}; - const cLink=re=>{const b=[...doc().querySelectorAll('a')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; - cB(/^Got it/); await wait(80); cB(/^Server Station/); await wait(150); - cB(/^1$/); await wait(120); - cB(/^Seat 1/); await wait(60); cT(/Calamari/); await wait(100); - cB(/^Seat 2/); await wait(60); cT(/Ribeye/); await wait(80); cB(/^Rare|^Med rare|^Medium/); cB(/Add to order/); await wait(100); - cB(/Whole table/); await wait(60); cT(/Bistro Burger/); await wait(120); - cB(/Send order/); await wait(200); - // fire everything at the Kitchen Line — bump until the order is READY - cLink(/change station/); await wait(120); cB(/^Kitchen Line/); await wait(150); - for(let i=0;i<12;i++){ const rc=(w.loadDB().records||[]).slice(-1)[0]; if(rc&&rc.status==='READY') break; cB(/Mark done here/); await wait(150); } - const readied=(w.loadDB().records||[]).slice(-1)[0].status; - // back to server, open the ready order, split by seat, pay each bucket - cLink(/change station/); await wait(120); cB(/^Server Station/); await wait(200); - const openIt=[...doc().querySelectorAll('button, .card, .line, a')].find(x=>/Calamari|Ribeye|Bistro Burger|#/.test(x.textContent||'')); if(openIt)openIt.click(); await wait(200); - const bySeat=cB(/By seat/); await wait(150); - const bucketRows=[...doc().querySelectorAll('.line')].filter(l=>/Seat 1|Seat 2|Shared/.test(l.textContent)).length; - // pay: cash, card, cash — advanceSeat moves to the next unpaid bucket after each - cB(/ cash$/); await wait(150); - cB(/Pay by card|by card/); await wait(200); - cB(/ cash$/); await wait(200); - const rec=(w.loadDB().records||[]).slice(-1)[0]; - const tagged=(rec.tenders||[]).filter(t=>t.seatKey).length; - return { readied, bySeat, bucketRows, status:rec.status, balance:w.recordBalance(rec), tenders:(rec.tenders||[]).length, tagged }; + // seat1 $12, seat2 $34, shared $16 — no tax so the shares are exact + const r={ id:"RT", number:99, status:"READY", tenders:[], splitMode:"seat", + lines:[ {id:"a",name:"A",price:12,qty:1,seat:1}, {id:"b",name:"B",price:34,qty:1,seat:2}, {id:"c",name:"C",price:16,qty:1,seat:null} ] }; + const bkts=w.seatBuckets(r); const byLabel=l=>bkts.find(x=>x.label===l); + const amounts={ s1:w.seatAmount(r,byLabel("Seat 1")), s2:w.seatAmount(r,byLabel("Seat 2")), shared:w.seatAmount(r,byLabel("Shared")) }; + const bucketCount=bkts.length; + // pay each bucket with a seat-tagged tender; balance must only clear when all are paid + r.tenders.push({type:"cash",amount:12,seatKey:"s1"}); const bal1=w.recordBalance(r); const s1cleared=w.seatDue(r,byLabel("Seat 1"))===0; + r.tenders.push({type:"card",amount:34,seatKey:"s2"}); const bal2=w.recordBalance(r); + const stillOpen = bal2>0.0001; // shared not yet paid + r.tenders.push({type:"cash",amount:16,seatKey:"shared"}); + const balEnd=w.recordBalance(r); + const allTagged=(r.tenders||[]).every(t=>t.seatKey); + return { total:w.recordTotal(r), amounts, bucketCount, bal1, s1cleared, stillOpen, balEnd, allTagged }; }); await b.close(); server.close(); console.log('\n=== RESULTS ==='); - console.log('seat-split math (total 62; Seat 1=12, Seat 2=34, Shared=16):', unit.total===62 && unit.bkts['Seat 1']===12 && unit.bkts['Seat 2']===34 && unit.bkts['Shared']===16); - console.log('paying seat 1 leaves the rest due (bal 50 → 0):', unit.balAfter1===50 && unit.balEnd===0); - console.log('order fires to READY:', flow.readied==='READY'); - console.log('"By seat" shows a row per seat + shared (3):', flow.bySeat && flow.bucketRows===3); - console.log('paying each bucket closes the order, every tender seat-tagged:', flow.status==='PAID' && flow.balance<=0.0001 && flow.tagged===flow.tenders && flow.tenders>=3); + console.log('total $62; one bucket per seat + shared (3):', out.total===62 && out.bucketCount===3); + console.log('each seat billed its share (S1 12, S2 34, Shared 16):', out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16); + console.log('paying seat 1 clears only seat 1 (bal 50):', out.bal1===50 && out.s1cleared); + console.log('order stays open until the shared bucket is paid too:', out.stillOpen && out.balEnd===0); + console.log('every tender is tagged to its seat:', out.allTagged); console.log('console errors:', errors.length?errors:'NONE'); - const ok = unit.total===62 && unit.bkts['Seat 1']===12 && unit.bkts['Seat 2']===34 && unit.bkts['Shared']===16 && unit.balAfter1===50 && unit.balEnd===0 - && flow.readied==='READY' && flow.bySeat && flow.bucketRows===3 && flow.status==='PAID' && flow.balance<=0.0001 && flow.tagged===flow.tenders && flow.tenders>=3 && !errors.length; + const ok = out.total===62 && out.bucketCount===3 && out.amounts.s1===12 && out.amounts.s2===34 && out.amounts.shared===16 + && out.bal1===50 && out.s1cleared && out.stillOpen && out.balEnd===0 && out.allTagged && !errors.length; process.exit(ok?0:1); })().catch(e => { console.error('FATAL', e); process.exit(2); }); From 3aa82f397938b9385685d9481bd4ea442157f917 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:38:25 -0500 Subject: [PATCH 10/19] Server flow P2 #5: transfer / merge tables + move a guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveTable(from,to) moves a table's open check(s) to another table — a transfer when the target is empty, a merge when it already has a check — then frees the source (its turn ends, server clears). The floor table panel gets a "Move check to:" row (each other table; "(merge)" when the target is occupied). A per-line "seat" button on the checkout view moves a single item to another seat (or the whole table) — moving a guest. - pos.html: moveTable(); "Move check to:" UI in the floor panel; per-line seat reassign button in renderCheckout. - tests/table-transfer.js: pins merge (combine + free source) and transfer (move + occupy empty target). Verified live: merge t1→t2 (both checks on t2, t1 freed), transfer t2→t3 (checks move, t3 occupied, t2 freed). Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 6 ++--- pos.html | 25 ++++++++++++++++++++- tests/table-transfer.js | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/table-transfer.js diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index cba6c80..9361044 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -20,7 +20,7 @@ plan — not spelled out feature by feature.* | **Order by SEAT number** (seat 2's ribeye) | ✅ | seat picker at intake; seat rides the line → KDS/station/ticket/check (`seat-order.js`) | | Split the check | ✅ | even 2/3/4-way **and by seat** (`split.js`, `split-seat.js`) | | **Per-seat checks** (each guest pays their own) | ✅ | "By seat" at payment; tenders tagged to the seat (`split-seat.js`) | -| Transfer / merge tables, move a guest | ❌ | | +| Transfer / merge tables, move a guest | ✅ | move a check to another table (transfer/merge); per-line seat move (`table-transfer.js`) | | Server assignment (sections → a server, "my tables") | ✅ | assign a server to a table; "my tables" floor filter (`server-assign.js`) | | Reservations + waitlist | ❌ | `booking.js` is appointment-style, not restaurant reso/waitlist | @@ -121,8 +121,8 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by 3. ✅ Comps with reason (+ opt-in manager approval) — *shipped* - **P2 — runs a real dining room** 4. ✅ Server assignment + "my tables" — *shipped* - 5. Transfer / merge tables, move a guest — *next* - 6. Day-part menus + happy-hour pricing + 5. ✅ Transfer / merge tables, move a guest — *shipped* + 6. Day-part menus + happy-hour pricing — *next* - **P3 — reservations & the door** 7. Reservations + waitlist - **P4 — off-premise & self-serve** diff --git a/pos.html b/pos.html index 64bc2bf..43f8051 100644 --- a/pos.html +++ b/pos.html @@ -1488,6 +1488,21 @@ if(state===emptyId) delete DB.tables[id].server; // a cleared table is free again saveDB(DB); render(); } +// 🔀 TRANSFER / MERGE tables — move a table's open check(s) to another table. If the target is empty it's a +// transfer (party moved); if it already has a check it's a merge (two tables become one party). The source is +// then freed (its turn ends). Reuses setTableState so turn analytics and the server-clear stay consistent. +function moveTable(fromId, toId){ if(!toId || fromId===toId) return; + DB=loadDB(); const moved=(DB.records||[]).filter(r=>r.tableId===fromId && r.status!=="CLOSED" && r.status!=="REFUNDED"); + if(!moved.length){ flash("No open check on "+tableLabel(fromId)); return; } + moved.forEach(r=>{ r.tableId=toId; r._t=Date.now(); }); + DB.tables=DB.tables||{}; const src=DB.tables[fromId]||{}, tgt=DB.tables[toId]||{}; + const emptyId=floorStates()[0].id, occ=(floorStates()[1]||floorStates()[0]).id; + const wasOccupied=(tgt.state && tgt.state!==emptyId); + DB.tables[toId]=Object.assign({}, tgt, { state: wasOccupied?tgt.state:occ, since:Date.now(), seatedTs: tgt.seatedTs||src.seatedTs||Date.now(), server: tgt.server||src.server||null }); + saveDB(DB); + logActivity("access", (wasOccupied?"merged ":"moved ")+tableLabel(fromId)+" → "+tableLabel(toId)+" ("+moved.length+" check"+(moved.length>1?"s":"")+")"); + selTable=toId; setTableState(fromId, emptyId); // free the source (logs its turn, clears its server, saves + renders) +} function tableSeats(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&+t.seats)||0; } function tableLabel(id){ const t=((FLOW.floor&&FLOW.floor.tables)||[]).find(x=>x.id===id); return (t&&(t.label||t.id))||id; } // a check belongs to a table (restaurants): open orders tagged with tableId, and their running balance @@ -1556,6 +1571,12 @@ const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Server:"; sv.appendChild(lbl); serverStaff().forEach(s=>{ const b=document.createElement("button"); if(tableServer(t.id)===s.name) b.className="sel"; b.textContent=s.name; b.onclick=()=>assignServer(t.id, tableServer(t.id)===s.name?null:s.name); sv.appendChild(b); }); c.appendChild(sv); } + // 🔀 transfer / merge this table's open check to another table + if(tableOpenOrders(t.id).length){ const mv=document.createElement("div"); mv.className="opts"; mv.style.marginTop="8px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="6px"; lbl.textContent="Move check to:"; mv.appendChild(lbl); + (cfg.tables||[]).filter(x=>x.id!==t.id).forEach(x=>{ const occ=tableOpenOrders(x.id).length; const b=document.createElement("button"); + b.textContent=(x.label||x.id)+(occ?" (merge)":""); b.title=occ?("Merge onto "+(x.label||x.id)):("Move to "+(x.label||x.id)); b.onclick=()=>moveTable(t.id, x.id); mv.appendChild(b); }); + c.appendChild(mv); } app.appendChild(c); } } @@ -2343,7 +2364,9 @@ r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; const priceHtml=l.comped ? (""+money((l.comped.wasPrice||0)*l.qty)+" "+money(0)) : money(l.price*l.qty); row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+compChip(l)+""+priceHtml+""; - if(r.status==="READY" && station.view.money && !l.returned){ const cbtn=document.createElement("button"); cbtn.textContent=l.comped?"un-comp":"comp"; cbtn.title=l.comped?"Undo the comp":"Comp this item (on the house)"; cbtn.style.cssText="padding:2px 8px;margin-left:6px"; cbtn.onclick=()=>compLine(r,l); row.appendChild(cbtn); } + if(r.status==="READY" && station.view.money && !l.returned){ const cbtn=document.createElement("button"); cbtn.textContent=l.comped?"un-comp":"comp"; cbtn.title=l.comped?"Undo the comp":"Comp this item (on the house)"; cbtn.style.cssText="padding:2px 8px;margin-left:6px"; cbtn.onclick=()=>compLine(r,l); row.appendChild(cbtn); + if(r.tableId || orderHasSeats(r)){ const sbtn=document.createElement("button"); sbtn.textContent="seat"; sbtn.title="Move this item to another seat"; sbtn.style.cssText="padding:2px 8px;margin-left:4px"; + sbtn.onclick=()=>{ const v=prompt("Move "+l.name+" to which seat? (number, or blank for whole table)", l.seat||""); if(v===null) return; const n=parseInt(v,10); l.seat=(v.trim()===""?null:(n>0?n:l.seat)); saveDB(DB); logActivity("access","moved "+l.name+" → "+(l.seat?("seat "+l.seat):"whole table")+" on #"+r.number, r.number); render(); }; row.appendChild(sbtn); } } c.appendChild(row); }); // tax area (destination sales tax): for delivery, tax at the delivery address's rate, not the shop's. // Shown only when tax zones are configured; "Shop" = the base (origin) rate. diff --git a/tests/table-transfer.js b/tests/table-transfer.js new file mode 100644 index 0000000..bd1cc8f --- /dev/null +++ b/tests/table-transfer.js @@ -0,0 +1,46 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔀 TRANSFER / MERGE tables: move a table's open check to another table — a transfer when the target is empty, +// a merge when it already has a check. The source table is freed either way. (Money logic pinned on the built engine.) +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + w.DB=w.loadDB(); + w.DB.records=[ {id:'A1',number:1,status:'READY',tableId:'t1',tenders:[],lines:[{id:'a',name:'Steak',price:30,qty:1}]}, + {id:'B1',number:2,status:'READY',tableId:'t2',tenders:[],lines:[{id:'b',name:'Wine',price:11,qty:1}]} ]; + const empty=w.floorStates?w.floorStates()[0].id:'empty'; + w.DB.tables={ t1:{state:'assigned',since:Date.now(),server:'Riley'}, t2:{state:'assigned',since:Date.now(),server:'Sam'} }; + w.saveDB(w.DB); + w.moveTable('t1','t2'); // merge (t2 occupied) + let db=w.loadDB(); + const merge={ onT2:(db.records||[]).filter(r=>r.tableId==='t2').length, onT1:(db.records||[]).filter(r=>r.tableId==='t1').length, t1empty:(db.tables.t1||{}).state===empty }; + w.moveTable('t2','t3'); // transfer (t3 empty) + db=w.loadDB(); + const transfer={ onT3:(db.records||[]).filter(r=>r.tableId==='t3').length, t3occupied:(db.tables.t3||{}).state && (db.tables.t3||{}).state!==empty, t2empty:(db.tables.t2||{}).state===empty }; + return { merge, transfer }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('merge onto an occupied table combines checks + frees the source:', out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty); + console.log('transfer to an empty table moves the checks + occupies it:', out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.merge.onT2===2 && out.merge.onT1===0 && out.merge.t1empty && out.transfer.onT3===2 && out.transfer.t3occupied && out.transfer.t2empty && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 0f87225e6be19fc35bc69ca33ce4c9735d75c250 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:41:33 -0500 Subject: [PATCH 11/19] Server flow P2 #6: day-part menus + happy-hour pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time windows on the menu. A window is {days?, from, to} ("HH:MM"; from>to spans midnight). Two uses: - day-part availability: an item's `avail` window hides/disables it off-hours (breakfast till 11) — shown on the tile with the window. - happy-hour pricing: an item's `happyPrice` applies while the shop's `endpoints.happyHour` window is live; the tile strikes the regular price and shows an "⏰ happy hour" badge, and it rings at the happy price. - pos.html: hmToMin/withinWindow/happyActive/itemAvailNow/itemPriceNow; priceItem applies the happy price; intake tile shows happy price + off-hours state. - builder.html: bistro gets a 3–6pm happyHour window; House Wine $11→$6 and Draft Beer $7→$4 happy prices. - tests/dayparts.js: time injected for determinism — happy on at 4pm/off at noon, wine $6 vs $11, brunch window open 10am/closed 4pm, all-day + overnight windows. Verified live with injected times. Co-Authored-By: Claude Opus 4.8 --- builder.html | 6 ++--- docs/RESTAURANT-ROADMAP.md | 8 +++---- pos.html | 20 ++++++++++++++-- tests/dayparts.js | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/dayparts.js diff --git a/builder.html b/builder.html index 38b6b3c..9d34159 100644 --- a/builder.html +++ b/builder.html @@ -444,7 +444,7 @@ bistro: { flowId:"bistro", label:"Full-service restaurant", topology:"hub-and-spoke", blurb:"Dine-in — tables, floor plan, kitchen & bar", branding:{ name:"My Bistro", brandColor:"#7a1f2b" }, staff:[ {id:"own",name:"Owner",pin:"1111",role:"owner"}, {id:"mgr",name:"Manager",pin:"2222",role:"manager"}, {id:"srv1",name:"Riley",pin:"3333"}, {id:"srv2",name:"Sam",pin:"4444"} ], // servers own tables; owner/manager approve comps - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, coursing:true }, + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, coursing:true }, // happy hour 3–6pm courses:["Appetizers","Entrées","Desserts"], timer:{ mode:"aging", warnSec:600, badSec:1200, label:"ticket" }, kds:{warnSec:420,badSec:900}, floor:{ label:"Dining Room", warnSec:900, sections:["Main","Patio","Bar"], tables:[ @@ -458,8 +458,8 @@ {id:"steak",name:"Ribeye",price:34,category:"entree",path:["line"],course:2,modifiers:[{group:"Temp",required:true,options:[{name:"Rare",price:0},{name:"Med rare",price:0},{name:"Medium",price:0},{name:"Well",price:0}]}]}, {id:"salmon",name:"Salmon",price:26,category:"entree",path:["line"],course:2}, {id:"burger",name:"Bistro Burger",price:16,category:"entree",path:["line"],course:2}, - {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1,ageRestricted:21}, - {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1,ageRestricted:21}, + {id:"wine",name:"House Wine",price:11,category:"drink",path:["bar"],course:1,ageRestricted:21,happyPrice:6}, + {id:"beer",name:"Draft Beer",price:7,category:"drink",path:["bar"],course:1,ageRestricted:21,happyPrice:4}, {id:"cake",name:"Chocolate Cake",price:10,category:"dessert",path:["cold"],course:3} ], stations:[ diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 9361044..9198b6e 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -42,8 +42,8 @@ plan — not spelled out feature by feature.* | Modifiers (required/optional), add-ons, price deltas | ✅ | e.g. Patty single/double, cheese, bacon | | Combos / upsell ("make it a combo") | ✅ | add-on combos | | Open price / by-weight | ✅ | `custom-price.js`, `by-weight.js` | -| **Day-part menus** (breakfast till 11) | ❌ | | -| **Time-based / happy-hour pricing** | ❌ | | +| **Day-part menus** (breakfast till 11) | ✅ | item `avail` window hides it off-hours (`dayparts.js`) | +| **Time-based / happy-hour pricing** | ✅ | item `happyPrice` + shop `happyHour` window (`dayparts.js`) | ## 4. Payments & checkout | Capability | Status | Notes | @@ -122,9 +122,9 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by - **P2 — runs a real dining room** 4. ✅ Server assignment + "my tables" — *shipped* 5. ✅ Transfer / merge tables, move a guest — *shipped* - 6. Day-part menus + happy-hour pricing — *next* + 6. ✅ Day-part menus + happy-hour pricing — *shipped* - **P3 — reservations & the door** - 7. Reservations + waitlist + 7. Reservations + waitlist — *next* - **P4 — off-premise & self-serve** 8. Pay-at-table / QR pay 9. Online ordering / QR menu; 3rd-party delivery intake diff --git a/pos.html b/pos.html index 43f8051..783474f 100644 --- a/pos.html +++ b/pos.html @@ -714,6 +714,16 @@ } function isLowStock(item){ return tracksStock(item) && stockOnHand(item) <= (item.reorderAt!=null?item.reorderAt:0); } function isOutOfStock(item){ return tracksStock(item) && stockOnHand(item) <= 0; } +// ⏰ TIME WINDOWS — day-part menus (breakfast till 11) + happy-hour pricing. A window is {days?:[0-6], from, to} +// ("HH:MM"); days omitted = every day; from/to omitted = all day; from>to spans midnight. All read the live clock. +function hmToMin(s){ const m=String(s||"").match(/^(\d{1,2}):(\d{2})$/); return m?(+m[1]*60 + +m[2]):null; } +function withinWindow(win, at){ if(!win) return false; const now=at||new Date(); const day=now.getDay(); + if(Array.isArray(win.days) && win.days.length && win.days.indexOf(day)<0) return false; + const f=hmToMin(win.from), t=hmToMin(win.to); if(f==null||t==null) return true; + const cur=now.getHours()*60+now.getMinutes(); return f<=t ? (cur>=f&&cur=f||cur{ - const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const out=is86(item)||isOutOfStock(item); + const b=document.createElement("button"); b.className="tile"; const rem=remainingOf(item); const offHours=!itemAvailNow(item); const out=is86(item)||isOutOfStock(item)||offHours; item.__out=out; if(out) b.className="tile eightysix"; const via=item.path.length?item.path.join(" → "):"no prep"; - const priceLbl = item.customPrice ? "price at ring" : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):""); + const happy = item.happyPrice!=null && happyActive(); + const priceLbl = item.customPrice ? "price at ring" + : (happy ? (""+money(item.price)+" "+money(item.happyPrice)+" ⏰ happy hour") + : money(item.price)+(item.byWeight?" / "+(item.unit||"lb"):"")); let sub = station.view.money?priceLbl+" · "+via:via; + if(offHours) sub += " · "+(item.avail&&(item.avail.from||item.avail.to)?((item.avail.from||"")+"–"+(item.avail.to||"")):"off hours")+""; if(item.par!=null) sub += is86(item) ? " · 86" : " · "+rem+" left"; if(tracksStock(item)) sub += isOutOfStock(item) ? " · out of stock" : " · "+stockOnHand(item)+" in stock"+(isLowStock(item)?" low":""); b.innerHTML = item.name + ""+sub+""; @@ -2167,6 +2181,8 @@ if(isNaN(n)||n<0){ flash("Enter a price"); return null; } return {...item, price:+n.toFixed(2)}; } + // ⏰ happy-hour: ring the happy price when the window is live (a fixed-price item) + const now=itemPriceNow(item); if(now!==item.price) return {...item, price:now}; return item; } function sendDraft(){ diff --git a/tests/dayparts.js b/tests/dayparts.js new file mode 100644 index 0000000..985fa23 --- /dev/null +++ b/tests/dayparts.js @@ -0,0 +1,47 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// ⏰ DAY-PART MENUS + HAPPY-HOUR PRICING — items can carry an availability window (breakfast till 11) and a +// happy-hour price that applies inside a shop window. Time is injected so the test is deterministic. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + const wine=w.CUSTOMPOS_FLOW.catalog.find(x=>x.name==='House Wine'); + const at4=new Date(2026,0,1,16,0), at12=new Date(2026,0,1,12,0), at10=new Date(2026,0,1,10,0); + const brunch={from:"09:00",to:"11:00"}; + return { + wineHasHappy: wine && wine.happyPrice===6, + happyOn4: w.happyActive(at4), happyOff12: w.happyActive(at12), + wine4: w.itemPriceNow(wine,at4), wine12: w.itemPriceNow(wine,at12), + brunchOpen: w.itemAvailNow({avail:brunch}, at10), brunchClosed: w.itemAvailNow({avail:brunch}, at4), + allDay: w.itemAvailNow({}, at4), + overnight: w.withinWindow({from:"22:00",to:"02:00"}, new Date(2026,0,1,1,0)) + }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('bistro bar drink carries a happy-hour price:', out.wineHasHappy); + console.log('happy hour is on at 4pm, off at noon:', out.happyOn4 && !out.happyOff12); + console.log('wine rings $6 in happy hour, $11 otherwise:', out.wine4===6 && out.wine12===11); + console.log('day-part item available in-window (10am), hidden out (4pm):', out.brunchOpen && !out.brunchClosed); + console.log('an item with no window is always available; overnight window wraps midnight:', out.allDay && out.overnight); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.wineHasHappy && out.happyOn4 && !out.happyOff12 && out.wine4===6 && out.wine12===11 && out.brunchOpen && !out.brunchClosed && out.allDay && out.overnight && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From f928ab81cb66a742de85924e845b94157f1e1263 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:45:51 -0500 Subject: [PATCH 12/19] Server flow P3 #7: reservations + waitlist (the host stand) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host stand at the top of the floor screen. Reservations are booked for a time today (name, party, time, phone); the waitlist holds walk-ins with a quoted wait and shows how long they've waited. Either can be seated — onto the currently selected table, which then shows occupied — and nothing is hard-deleted: a reservation is cancelled, a waitlist party marked seated or left. - pos.html: reservation/waitlist data fns (add/seat/cancel/leave, today/active filters, wait timer); renderHostStand() cards with quick-add rows, wired into renderFloor. seat* save the status BEFORE setTableState (which reloads DB). - tests/reservations.js: book + waitlist, seat each onto a table (occupies it), soft-cancel drops from the active list but keeps the record. Verified live: reservation seats onto t2, walk-in onto t3, both tables occupied; cancel is soft. Co-Authored-By: Claude Opus 4.8 --- docs/RESTAURANT-ROADMAP.md | 4 +-- pos.html | 49 ++++++++++++++++++++++++++++++++ tests/reservations.js | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/reservations.js diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 9198b6e..1caa713 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -22,7 +22,7 @@ plan — not spelled out feature by feature.* | **Per-seat checks** (each guest pays their own) | ✅ | "By seat" at payment; tenders tagged to the seat (`split-seat.js`) | | Transfer / merge tables, move a guest | ✅ | move a check to another table (transfer/merge); per-line seat move (`table-transfer.js`) | | Server assignment (sections → a server, "my tables") | ✅ | assign a server to a table; "my tables" floor filter (`server-assign.js`) | -| Reservations + waitlist | ❌ | `booking.js` is appointment-style, not restaurant reso/waitlist | +| Reservations + waitlist | ✅ | host stand on the floor: book/seat/cancel + waitlist add/seat/clear (`reservations.js`) | ## 2. Kitchen / expo | Capability | Status | Notes | @@ -124,7 +124,7 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by 5. ✅ Transfer / merge tables, move a guest — *shipped* 6. ✅ Day-part menus + happy-hour pricing — *shipped* - **P3 — reservations & the door** - 7. Reservations + waitlist — *next* + 7. ✅ Reservations + waitlist — *shipped* - **P4 — off-premise & self-serve** 8. Pay-at-table / QR pay 9. Online ordering / QR menu; 3rd-party delivery intake diff --git a/pos.html b/pos.html index 783474f..63aca2c 100644 --- a/pos.html +++ b/pos.html @@ -1520,9 +1520,58 @@ function tableOpenTotal(id){ return tableOpenOrders(id).reduce((s,r)=>s+recordBalance(r),0); } function turnsToday(){ return (DB.turns||[]).filter(x=>x.day===todayStr()); } function turnStats(){ const ts=turnsToday(); const n=ts.length; return { n, avgMs:n?ts.reduce((s,x)=>s+x.dur,0)/n:0, covers:ts.reduce((s,x)=>s+tableSeats(x.table),0) }; } +/* 🕐 RESERVATIONS + WAITLIST — the host stand, run from the floor. Reservations are booked for a time today; + the waitlist holds walk-ins with a quoted wait. Both can be "seated" (optionally onto the selected table) or + cleared. Records are never hard-deleted — a reservation is cancelled, a waitlist party marked seated/left. */ +function todayReservations(){ return (DB.reservations||[]).filter(r=>r.day===todayStr() && r.status!=="cancelled").sort((a,b)=>String(a.time||"").localeCompare(String(b.time||""))); } +function addReservation(name, party, time, phone){ name=(name||"").trim(); if(!name){ flash("Name for the reservation?"); return; } + DB=loadDB(); DB.reservations=DB.reservations||[]; DB.reservations.push({ id:"RSV"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), time:(time||"").trim(), day:todayStr(), status:"booked" }); + logActivity("access","reservation · "+name+" ×"+(Math.max(1,+party||1))+((time||"").trim()?" @ "+time.trim():"")); saveDB(DB); render(); } +function seatReservation(id, tableId){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="seated"; if(tableId) r.tableId=tableId; logActivity("access","seated reservation · "+r.name); saveDB(DB); + if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save the seated status BEFORE setTableState (it reloads DB) +function cancelReservation(id){ DB=loadDB(); const r=(DB.reservations||[]).find(x=>x.id===id); if(!r) return; r.status="cancelled"; logActivity("access","cancelled reservation · "+r.name); saveDB(DB); render(); } +function activeWaitlist(){ return (DB.waitlist||[]).filter(w=>w.status==="waiting").sort((a,b)=>(a.addedTs||0)-(b.addedTs||0)); } +function waitlistWait(w){ return Math.round((Date.now()-(w.addedTs||Date.now()))/60000); } +function addWaitlist(name, party, quote, phone){ name=(name||"").trim(); if(!name){ flash("Name for the waitlist?"); return; } + DB=loadDB(); DB.waitlist=DB.waitlist||[]; DB.waitlist.push({ id:"WL"+Date.now(), name, phone:(phone||"").trim(), party:Math.max(1,+party||1), quote:Math.max(0,+quote||0), addedTs:Date.now(), status:"waiting" }); + logActivity("access","waitlist + "+name+" ×"+(Math.max(1,+party||1))+((+quote||0)?" ~"+(+quote)+"m":"")); saveDB(DB); render(); } +function seatWaitlist(id, tableId){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="seated"; if(tableId) w.tableId=tableId; logActivity("access","seated waitlist · "+w.name); saveDB(DB); + if(tableId) setTableState(tableId, (floorStates()[1]||floorStates()[0]).id); else render(); } // save before setTableState (it reloads DB) +function leaveWaitlist(id){ DB=loadDB(); const w=(DB.waitlist||[]).find(x=>x.id===id); if(!w) return; w.status="left"; logActivity("access","waitlist left · "+w.name); saveDB(DB); render(); } +// the host stand UI — a Reservations card and a Waitlist card, each with a quick add row + "Seat"/"clear". +// "Seat" uses the currently-selected table when one is picked, so the host taps a table then seats the party. +function renderHostStand(app){ + const seatTarget = selTable ? (" → "+tableLabel(selTable)) : ""; + const mkAdd=(fields, onAdd)=>{ const row=document.createElement("div"); row.className="opts"; row.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px"; + const els={}; fields.forEach(f=>{ const i=document.createElement("input"); i.placeholder=f.ph; i.id=f.id; if(f.w) i.style.width=f.w; if(f.type) i.type=f.type; row.appendChild(i); els[f.id]=i; }); + const add=document.createElement("button"); add.className="primary"; add.textContent="Add"; add.onclick=()=>{ onAdd(els); }; row.appendChild(add); return row; }; + // Reservations + const rc=document.createElement("div"); rc.className="card"; rc.style.marginBottom="10px"; + rc.innerHTML="
🕐 Reservations "+todayReservations().filter(r=>r.status==="booked").length+" booked
"; + todayReservations().forEach(r=>{ const row=document.createElement("div"); row.className="line"; + row.innerHTML=""+(r.status==="seated"?"✓ ":"")+""+(r.time||"—")+" · "+r.name+" ×"+r.party+(r.phone?" "+r.phone+"":"")+(r.status==="seated"?" seated":"")+""; + if(r.status!=="seated"){ const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatReservation(r.id, selTable||null); row.appendChild(s); + const x=document.createElement("button"); x.textContent="✕"; x.title="Cancel"; x.style.marginLeft="4px"; x.onclick=()=>cancelReservation(r.id); row.appendChild(x); } + rc.appendChild(row); }); + rc.appendChild(mkAdd([{id:"rsvName",ph:"name"},{id:"rsvParty",ph:"party",w:"64px",type:"number"},{id:"rsvTime",ph:"time e.g. 7:00",w:"110px"},{id:"rsvPhone",ph:"phone (optional)"}], + els=>{ addReservation(els.rsvName.value, els.rsvParty.value, els.rsvTime.value, els.rsvPhone.value); })); + app.appendChild(rc); + // Waitlist + const wc=document.createElement("div"); wc.className="card"; wc.style.marginBottom="10px"; + wc.innerHTML="
📝 Waitlist "+activeWaitlist().length+" waiting
"; + activeWaitlist().forEach(w=>{ const row=document.createElement("div"); row.className="line"; const waited=waitlistWait(w); + row.innerHTML=""+w.name+" ×"+w.party+(w.quote?" · quoted "+w.quote+"m":"")+" · waiting "+waited+"m"; + const s=document.createElement("button"); s.className="good"; s.textContent="Seat"+seatTarget; s.onclick=()=>seatWaitlist(w.id, selTable||null); row.appendChild(s); + const x=document.createElement("button"); x.textContent="✕"; x.title="Left"; x.style.marginLeft="4px"; x.onclick=()=>leaveWaitlist(w.id); row.appendChild(x); + wc.appendChild(row); }); + wc.appendChild(mkAdd([{id:"wlName",ph:"name"},{id:"wlParty",ph:"party",w:"64px",type:"number"},{id:"wlQuote",ph:"quote min",w:"90px",type:"number"},{id:"wlPhone",ph:"phone (optional)"}], + els=>{ addWaitlist(els.wlName.value, els.wlParty.value, els.wlQuote.value, els.wlPhone.value); })); + app.appendChild(wc); +} function renderFloor(app, station){ const cfg=FLOW.floor||{ tables:[] }; app.innerHTML="

"+station.label+"

"; + renderHostStand(app); // 🕐 reservations + waitlist at the top of the host's floor screen const tables=(cfg.tables||[]).filter(t=> (!floorSection || (t.section||"")===floorSection) && (!floorServer || tableServer(t.id)===floorServer)); const sections=cfg.sections || [...new Set((cfg.tables||[]).map(t=>t.section).filter(Boolean))]; // section filter (server sees a section; manager sees all) diff --git a/tests/reservations.js b/tests/reservations.js new file mode 100644 index 0000000..5959060 --- /dev/null +++ b/tests/reservations.js @@ -0,0 +1,57 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🕐 RESERVATIONS + WAITLIST (the host stand on the floor): book/seat/cancel reservations; add/seat/clear the +// waitlist. Seating onto a table occupies it. Nothing is hard-deleted (cancelled / left / seated). +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Floor/); await wait(200); + const hostCards=/Reservations/.test(doc().body.innerText) && /Waitlist/.test(doc().body.innerText); + w.addReservation('Test Party','4','7:00',''); w.addWaitlist('Joe','2','15',''); + let db=w.loadDB(); + const booked=(db.reservations||[]).some(r=>r.name==='Test Party' && r.status==='booked' && r.party===4); + const waiting=(db.waitlist||[]).some(x=>x.name==='Joe' && x.status==='waiting'); + const rid=(db.reservations||[]).slice(-1)[0].id, wid=(db.waitlist||[]).slice(-1)[0].id; + const empty=w.floorStates()[0].id; + w.selTable='t2'; w.seatReservation(rid,'t2'); + w.selTable='t3'; w.seatWaitlist(wid,'t3'); + db=w.loadDB(); + const seatedResv=(db.reservations||[]).find(r=>r.id===rid); const seatedWl=(db.waitlist||[]).find(x=>x.id===wid); + const seatedOk = seatedResv.status==='seated' && seatedResv.tableId==='t2' && (db.tables.t2||{}).state!==empty + && seatedWl.status==='seated' && (db.tables.t3||{}).state!==empty; + // cancel a fresh reservation, and it leaves the active list (soft, not deleted) + w.addReservation('Cancel Me','2','8:00',''); db=w.loadDB(); const cid=(db.reservations||[]).slice(-1)[0].id; + w.cancelReservation(cid); db=w.loadDB(); + const cancelledSoft = (db.reservations||[]).some(r=>r.id===cid && r.status==='cancelled') && !w.todayReservations().some(r=>r.id===cid); + return { hostCards, booked, waiting, seatedOk, cancelledSoft }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('host stand shows Reservations + Waitlist cards:', out.hostCards); + console.log('a reservation books and a walk-in joins the waitlist:', out.booked && out.waiting); + console.log('seating a reservation/walk-in onto a table occupies it:', out.seatedOk); + console.log('cancelling is soft (kept, dropped from the active list):', out.cancelledSoft); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.hostCards && out.booked && out.waiting && out.seatedOk && out.cancelledSoft && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From c962ad6f0ec4ce734d035def76aa76c6f59914ab Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 21:50:58 -0500 Subject: [PATCH 13/19] Server flow P4 #10: order types (dine-in/to-go/drive-thru) + online/delivery channel hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order types: an intake picker (configured via endpoints.orderTypes) sets the order type; it rides onto the record and shows as a chip on the order card, the KDS, and the printed ticket (🍽 dine-in / 🥡 to-go / 🚗 drive-thru). This is "drive-thru mode." A `channel` field + chip is the display hook for an order the hub hands in from online ordering or a delivery app, so it routes and shows like any other. Honest scope note (in docs/RESTAURANT-ROADMAP.md): pay-at-table/QR pay, online ordering/QR menu, and 3rd-party delivery INGESTION are NOT single-file engine features — they need the hub + a hosted customer-facing page + the merchant's own processor/delivery accounts. The engine side is ready; the rest is a hub/hosting project sequenced after Burger Barn's on-premise flow proves out. Not faked. - pos.html: orderTypes()/orderTypeChip()/channelChip(); intake order-type picker; rec.orderType/channel in sendDraft; chips on order card + KDS; ticket line. - builder.html: burgerbarn orderTypes [Dine-in,To-go,Drive-thru]; bistro [Dine-in,To-go]. - tests/order-types.js: picker + config + chips. Verified live: Drive-thru picked → on the record + ticket. Co-Authored-By: Claude Opus 4.8 --- builder.html | 4 ++-- docs/RESTAURANT-ROADMAP.md | 17 ++++++++------ pos.html | 22 ++++++++++++++++--- tests/order-types.js | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 tests/order-types.js diff --git a/builder.html b/builder.html index 9d34159..1fdde74 100644 --- a/builder.html +++ b/builder.html @@ -346,7 +346,7 @@ // confirms in the builder — the shop knows its own prices. "Make it a combo" is the Toast-style fries+drink upsell. burgerbarn: { flowId:"burgerbarn", label:"Hamburger Barn (burger joint)", topology:"hub-and-spoke", blurb:"The real Hamburger Barn menu — grill · fry · shakes · salad line", branding:{ name:"Hamburger Barn", brandColor:"#a3312a" }, - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[15,18,20]}, split:true} }, + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[15,18,20]}, split:true}, orderTypes:["Dine-in","To-go","Drive-thru"] }, timer:{ mode:"aging", warnSec:360, badSec:720, label:"ticket" }, capacity:{ perOrderMins:6, max:15 }, kds:{warnSec:360,badSec:720}, // Toast-style category tabs on the ordering screen (order = first appearance below) catLabels:{ burger:"Burgers", chicken:"Chicken", sandwich:"Sandwiches", plate:"Plates", texmex:"Tex-Mex", app:"Appetizers", salad:"Salads", soup:"Soups & Potatoes", side:"Sides", shake:"Shakes", dessert:"Desserts", kids:"Kids", drink:"Drinks" }, @@ -444,7 +444,7 @@ bistro: { flowId:"bistro", label:"Full-service restaurant", topology:"hub-and-spoke", blurb:"Dine-in — tables, floor plan, kitchen & bar", branding:{ name:"My Bistro", brandColor:"#7a1f2b" }, staff:[ {id:"own",name:"Owner",pin:"1111",role:"owner"}, {id:"mgr",name:"Manager",pin:"2222",role:"manager"}, {id:"srv1",name:"Riley",pin:"3333"}, {id:"srv2",name:"Sam",pin:"4444"} ], // servers own tables; owner/manager approve comps - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, coursing:true }, // happy hour 3–6pm + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, orderTypes:["Dine-in","To-go"], coursing:true }, // happy hour 3–6pm courses:["Appetizers","Entrées","Desserts"], timer:{ mode:"aging", warnSec:600, badSec:1200, label:"ticket" }, kds:{warnSec:420,badSec:900}, floor:{ label:"Dining Room", warnSec:900, sections:["Main","Patio","Bar"], tables:[ diff --git a/docs/RESTAURANT-ROADMAP.md b/docs/RESTAURANT-ROADMAP.md index 1caa713..a649b2a 100644 --- a/docs/RESTAURANT-ROADMAP.md +++ b/docs/RESTAURANT-ROADMAP.md @@ -56,7 +56,7 @@ plan — not spelled out feature by feature.* | Gift cards + loyalty points | ✅ | `loyalty-giftcards.js` | | House accounts / A/R, deposits, round-up | ✅ | `house-account.js`, `deposit-*.js`, `roundup.js` | | Receipt — print & email | ✅ | `print.js`, `notify.js`, `tax-receipt.js` | -| **Pay-at-table / QR pay** | ❌ | | +| **Pay-at-table / QR pay** | 🟡 | needs a hosted pay page (hub) the guest's phone loads — not a single-file engine feature | ## 5. Order types | Capability | Status | Notes | @@ -64,9 +64,9 @@ plan — not spelled out feature by feature.* | Dine-in | ✅ | | | Takeout / to-go | ✅ | | | Own delivery routes (driver, stops, confirm) | ✅ | `route.js` | -| **3rd-party delivery (DoorDash/UberEats) intake** | ❌ | | -| **Online ordering / QR menu** | ❌ | | -| **Drive-thru mode** | ❌ | if the concept needs it | +| **Drive-thru mode** | ✅ | order types (dine-in/to-go/drive-thru) on the ticket + KDS (`order-types.js`) | +| **3rd-party delivery (DoorDash/UberEats) intake** | 🟡 | engine has the `channel` hook + chip; ingestion needs the hub + a merchant delivery account | +| **Online ordering / QR menu** | 🟡 | needs a hosted customer-facing page (hub), not a single-file engine feature | ## 6. Staff & labor | Capability | Status | Notes | @@ -126,9 +126,12 @@ Sequenced by daily value to a sit-down burger place like Hamburger Barn, and by - **P3 — reservations & the door** 7. ✅ Reservations + waitlist — *shipped* - **P4 — off-premise & self-serve** - 8. Pay-at-table / QR pay - 9. Online ordering / QR menu; 3rd-party delivery intake - 10. Drive-thru (if needed) + 10. ✅ Drive-thru (order types: dine-in / to-go / drive-thru) — *shipped* + 8/9. Pay-at-table / QR pay · online ordering / QR menu · 3rd-party delivery intake — **need the hub + a + hosted customer-facing page + (for delivery) the merchant's own accounts.** These are honestly *not* + single-file engine features: the engine side is ready (an order `channel` hook + chip so an online or + delivery order displays and routes like any other), but the customer-facing web + processor + delivery + API are a hub/hosting/account project, sequenced after Burger Barn's on-premise flow proves out. Each item ships through the safety pipeline (draft → gates/tests → live preview → owner approval → deploy → rollback), with a test pinning the behavior — same as every change in this repo. diff --git a/pos.html b/pos.html index 63aca2c..9db65c2 100644 --- a/pos.html +++ b/pos.html @@ -525,6 +525,10 @@ function tableChip(r){ return r.tableId ? " 🍽 "+tableLabel(r.tableId)+"" : ""; } // 👤 which seat a line belongs to (dine-in seat-by-seat ordering). Blank when the line isn't tied to a seat. function seatChip(l){ return (l&&l.seat) ? " 👤 S"+l.seat+"" : ""; } +// 🚗 order type (dine-in / to-go / drive-thru) + channel (in-house vs an online/delivery order the hub handed in). +function orderTypes(){ return (FLOW.endpoints && FLOW.endpoints.orderTypes) || null; } +function orderTypeChip(r){ if(!r||!r.orderType) return ""; const ic=/drive/i.test(r.orderType)?"🚗":(/go|take/i.test(r.orderType)?"🥡":"🍽"); return " "+ic+" "+r.orderType+""; } +function channelChip(r){ return (r&&r.channel&&r.channel!=="in-house") ? " 🌐 "+r.channel+"" : ""; } // 🎁 a comped line (free, reason-coded, optionally manager-approved). Shows the reason on hover. function compChip(l){ return (l&&l.comped) ? " 🎁 comp" : ""; } function assembledAt(r){ if(!storesOn() || !r.storeId) return ""; const p=plantStore(); return (p && r.storeId!==p.id) ? "assembled at "+p.name : ""; } // a drop-store order is done at the plant @@ -1454,7 +1458,7 @@ if(!live.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="All caught up — no tickets working."; app.appendChild(e); } live.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const fc=r.firedCourse||1; - c.innerHTML="
#"+r.number+" "+kdsChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
"; + c.innerHTML="
#"+r.number+" "+kdsChip(r)+orderTypeChip(r)+channelChip(r)+(coursingOn()?" "+courseLabel(fc)+"":"")+""+r.createdAt+"
"; r.lines.filter(l=>courseReleased(r,l)).forEach(l=>{ const row=document.createElement("div"); row.className="line"; const where=lineComplete(l)?"ready":("at "+l.path[l.stage]); row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+""+where+""; c.appendChild(row); }); // held courses waiting to be fired @@ -2016,6 +2020,15 @@ function renderIntake(app, station){ if(!draft) draft = { lines:[] }; renderCustomerBar(app, station); + // 🚗 order type — dine-in / to-go / drive-thru (when the flow configures them) + if(orderTypes() && orderTypes().length){ + if(!draft.orderType) draft.orderType=orderTypes()[0]; + const ot=document.createElement("div"); ot.className="card"; ot.style.cssText="padding:8px 10px;margin-bottom:10px"; + const lbl=document.createElement("span"); lbl.className="muted"; lbl.style.marginRight="8px"; lbl.textContent="Order:"; ot.appendChild(lbl); + const opts=document.createElement("div"); opts.className="opts"; opts.style.display="inline-flex"; opts.style.flexWrap="wrap"; + orderTypes().forEach(tp=>{ const b=document.createElement("button"); if(draft.orderType===tp) b.className="sel"; b.textContent=tp; b.onclick=()=>{ draft.orderType=tp; render(); }; opts.appendChild(b); }); + ot.appendChild(opts); app.appendChild(ot); + } // table selector — when a floor is configured, ring this check to a table (restaurants) if(FLOW.floor && (FLOW.floor.tables||[]).length){ const tb=document.createElement("div"); tb.className="card"; tb.style.cssText="padding:8px 10px;margin-bottom:10px"; @@ -2237,6 +2250,8 @@ function sendDraft(){ DB=loadDB(); DB.seq++; const rec={ id:"R"+DB.seq, number:DB.seq, lines:draft.lines.map(l=>({...l, stage:0})), tenders:[], createdAt:new Date().toISOString().slice(11,16), ts:Date.now() }; + if(draft.orderType) rec.orderType=draft.orderType; // 🚗 dine-in / to-go / drive-thru + if(draft.channel) rec.channel=draft.channel; // 🌐 in-house vs an online/delivery order handed in by the hub if(storesOn()) rec.storeId = storeForOrder(draft.customer); // order belongs to a store (customer's, else this device's, else plant) if(draft.tableId){ rec.tableId = draft.tableId; // ring the check to its table; nudge an empty table to "occupied" DB.tables=DB.tables||{}; const cur=DB.tables[draft.tableId]; if(!cur || cur.state==="empty" || !cur.state){ DB.tables[draft.tableId]={ state:"ordered", since:Date.now(), seatedTs:(cur&&cur.seatedTs)||Date.now() }; } } @@ -2300,7 +2315,7 @@ const recs=DB.records.filter(r=> r.status==="INPROGRESS" && linesAtStation(r, station.id).length); if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="Nothing waiting."; app.appendChild(e); } recs.forEach(r=>{ const mine=linesAtStation(r, station.id); const c=document.createElement("div"); c.className="card"; - c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; + c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
"; let hasFlags=false, reconOk=true; mine.forEach(l=>{ const row=document.createElement("div"); row.className="line"; const next=l.path[l.stage+1]; const flagHtml=(l.flags||[]).map(f=>{ hasFlags=true; return " ⚠ "+f+""; }).join(""); @@ -2392,6 +2407,7 @@ const L=[]; L.push(((FLOW.branding&&FLOW.branding.name)||"Order").toUpperCase()); if(storesOn()&&r.storeId){ L.push(storeName(r.storeId)); const aa=assembledAt(r); if(aa) L.push(aa); } L.push("Order #"+r.number+" "+(r.createdAt||"")); if(r.customer) L.push((r.customer.name||"")+" "+(r.customer.phone||"")); + if(r.orderType||r.tableId||(r.channel&&r.channel!=="in-house")) L.push([r.orderType, r.tableId?("Table "+tableLabel(r.tableId)):"", (r.channel&&r.channel!=="in-house")?r.channel:""].filter(Boolean).join(" ")); L.push("------------------------------"); (r.lines||[]).forEach(l=>L.push(docLineText(l))); L.push("------------------------------"); const dd=dueDateText(r); if(dd) L.push("Promised: "+dd); return L.join("\n"); @@ -2425,7 +2441,7 @@ const recs=DB.records.filter(r=> r.status==="READY"||r.status==="PAID"); if(!recs.length){ const e=document.createElement("div"); e.className="empty card"; e.textContent="No orders ready to check out."; app.appendChild(e); } recs.forEach(r=>{ const c=document.createElement("div"); c.className="card"; const bal=recordBalance(r); - c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+""+r.createdAt+"
"; + c.innerHTML="
#"+r.number+" "+r.status+""+clockChip(r)+storeChip(r)+tableChip(r)+orderTypeChip(r)+channelChip(r)+""+r.createdAt+"
"; r.lines.forEach(l=>{ const row=document.createElement("div"); row.className="line"; const priceHtml=l.comped ? (""+money((l.comped.wasPrice||0)*l.qty)+" "+money(0)) : money(l.price*l.qty); row.innerHTML=""+l.qty+"× "+l.name+(l.modLabel?" "+l.modLabel+"":"")+seatChip(l)+compChip(l)+""+priceHtml+""; diff --git a/tests/order-types.js b/tests/order-types.js new file mode 100644 index 0000000..9e44750 --- /dev/null +++ b/tests/order-types.js @@ -0,0 +1,45 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🚗 ORDER TYPES (dine-in / to-go / drive-thru) + the online/delivery CHANNEL hook. The intake picker sets the +// type; it rides onto the record, the ticket and the KDS chip. Channel is the display hook for an order handed +// in by the hub from online ordering / a delivery app (that ingestion is hub + account work, not the engine). +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(200); + const picker=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>/^Dine-in$|^To-go$|^Drive-thru$/.test(x)); + const typeChip=w.orderTypeChip({orderType:"Drive-thru"}); + const chanChip=w.channelChip({channel:"DoorDash"}); + const cfgTypes=(w.CUSTOMPOS_FLOW.endpoints.orderTypes||[]).join(","); + return { picker, typeChip, chanChip, cfgTypes }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('intake shows the order-type picker (Dine-in/To-go/Drive-thru):', out.picker.length===3); + console.log('config carries the order types:', out.cfgTypes==="Dine-in,To-go,Drive-thru"); + console.log('order-type chip renders the type:', /Drive-thru/.test(out.typeChip)); + console.log('channel chip renders an online/delivery source:', /DoorDash/.test(out.chanChip)); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.picker.length===3 && out.cfgTypes==="Dine-in,To-go,Drive-thru" && /Drive-thru/.test(out.typeChip) && /DoorDash/.test(out.chanChip) && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 443f9c269a64b0c44781f4ee2bdf0441e55388d3 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 23:28:58 -0500 Subject: [PATCH 14/19] =?UTF-8?q?docs:=20market=20grievance=20research=20?= =?UTF-8?q?=E2=80=94=20what=20restaurant=20operators=20want=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gathered complaints + feature-wants from restaurant-POS reviews and forums (Toast, Square, Aloha, Clover, Lightspeed, TouchBistro), mapped to what customPOS already does vs real gaps. Findings: - The #1 grievance everywhere is lock-in & cost (proprietary hardware, locked processing, contract auto-renewal, features as $75/mo add-ons) — which is exactly the sovereign model's pitch, already true. - Operational asks: menu items that turn off when 86'd/out (have; day-parts now gate lunch-at-dinner), station routing on complex menus (have), offline reliability (have), built-in loyalty (have). - New gaps worth building, prioritized: nested/conditional modifiers; a one-screen daily close + clean tip/tip-out export; suggested-add-on upsell; wastage log; delivery-driver report; tip-out by role + attribution on transfer. Captured as data (the pattern of complaints), sources listed. Owner prioritizes; real Burger Barn grievances outrank a forum. Co-Authored-By: Claude Opus 4.8 --- docs/MARKET-GRIEVANCES.md | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/MARKET-GRIEVANCES.md diff --git a/docs/MARKET-GRIEVANCES.md b/docs/MARKET-GRIEVANCES.md new file mode 100644 index 0000000..d578553 --- /dev/null +++ b/docs/MARKET-GRIEVANCES.md @@ -0,0 +1,68 @@ +# 🗣️ What restaurant operators want changed — market research + +*Grievances and feature-wants gathered from restaurant-POS reviews and forums (Toast, Square for Restaurants, +Aloha, Clover, Lightspeed, TouchBistro) — mapped to what customPOS already does, so the roadmap is driven by what +real operators complain about, not guesswork. Sources at the bottom. (This is captured **as data** — the point is +the pattern of complaints, not any one review.)* + +> Companion to [RESTAURANT-ROADMAP.md](RESTAURANT-ROADMAP.md) (our capability map) and +> [BURGER-BARN-STAGE.md](BURGER-BARN-STAGE.md) (grievance capture on-site). **Legend:** ✅ we already answer it · +> 🟡 partial · 🆕 a gap worth building. + +## The megatheme: lock-in & cost — and it's *our whole pitch* +This is the #1 thing operators say they'd change, on every platform. It is precisely what the sovereign model +exists to answer — so this isn't a to-build list, it's the sales story, already true: + +| What operators hate | customPOS | +|---|---| +| **Proprietary hardware** — must buy Toast's Android terminals ($399–$799+), can't use your own; replacements only from them | ✅ Runs in a browser on hardware you already own; buy replacements anywhere | +| **Locked processing rates** (2.49–3.5%+), can't bring your own processor | ✅ We *recommend* a processor; the merchant account is yours — PCI never lands on us | +| **Contract auto-renewal** (2–3 yr, 30–60 day escape window) + hardware financing you still owe after leaving | ✅ You own the code; leave anytime, take it with you | +| **Core features as $75/mo add-ons** (online ordering, loyalty, marketing, gift cards, team mgmt each billed) | ✅ One instance, features included; revenue is the *service*, not per-feature upsell | +| **Online-ordering commissions** (2.5–3.5% on top of the fee) | 🟡 Online ordering is the hub project — but never a per-order commission | +| **Poor / slow support**, tier-1 can't help, escalations take days | ✅ The relationship *is* the product — "Claude keeps your POS perfect" | + +## Operational grievances (the build signal) +| Grievance (who complains) | Our status | Note | +|---|---|---| +| **Cloud POS dies when the internet drops** — can't ring, can't take cards | ✅ | Offline-first single file keeps ringing; syncs on reconnect. *Honest caveat: live card **authorization** still needs a connection — store-and-forward is a processor feature.* | +| **Square can't route to the right kitchen station** on complex menus | ✅ | Per-item station fan-out is core | +| **Menu doesn't turn items off when 86'd / out of stock** | ✅ | 86/par + stock auto-disable; **day-part windows** now gate lunch-at-dinner etc. | +| **Complex/conditional modifier trees are hard to build** (Toast) | 🟡→🆕 | We have modifiers/add-ons; **nested/conditional modifiers** (e.g. "add cheese → which cheese") is a real gap | +| **KDS is rigid, little customization** (Toast) | ✅🟡 | We have a KDS with timers/bump/coursing; layout options could go further | +| **Reports are weak — need to pull several to get one answer; accounting/tips-in-payroll painful** | 🟡→🆕 | We have reports/P&L/paystub; a **one-screen daily "close" report** + cleaner tip export is worth building | +| **No delivery-driver report** — who took which delivery | 🆕 | Small, concrete: a driver/delivery summary | +| **No upsell suggestions** (Square/Toast weak) | 🆕 | We have combos; a **"suggested add-on" prompt** at ring/checkout is cheap and wanted | +| **Inventory is shallow — no wastage/spoilage tracking** (vs Lightspeed) | 🟡→🆕 | We track stock + reorder; a **wastage/waste-log** adjust is the gap | +| **Tips lost when a tab/table is transferred**; tip-out to support staff clumsy | 🟡 | Our transfer moves the check (tips ride the tenders); **tip-out by role** + keeping server attribution on transfer is worth pinning | +| **Loyalty should be built in, no extra app** | ✅ | Loyalty + gift cards are core | +| **Weak third-party integrations** (QuickBooks, delivery, etc.) | 🟡 | QBO exists on the Ozark instance; delivery ingestion is the hub project | +| **Hard to learn / long training** | ✅ | The builder + plain UI; training in hours not days | + +## Where we already win (say it out loud) +Sovereign ownership · no proprietary hardware · bring-your-own processor · **offline-first reliability** · per-item +kitchen routing · built-in loyalty/gift/house-accounts · the full server flow we just built (seat-by-seat, split +by seat, comps, server assignment, transfer/merge, day-parts/happy-hour, reservations/waitlist, order types) · +and the moat: **say a grievance in plain English → it's changed**, which is the exact opposite of "escalation +takes days." + +## New gaps worth building (prioritized from the research) +1. **Nested / conditional modifiers** — the most-cited menu-build frustration. +2. **One-screen daily close report** + clean tip / tip-out export (payroll pain). +3. **Suggested add-on / upsell prompt** — wanted, and cheap on top of combos. +4. **Wastage / waste-log** in inventory — the inventory depth operators miss. +5. **Delivery-driver report** — small, concrete, repeatedly requested. +6. **Tip-out by role + tip attribution on transfer** — bartender/server pain. + +These are recommendations, not commitments — the owner prioritizes (and real Burger Barn grievances outrank a +forum). Each ships through the safety pipeline with a test, like everything here. + +## Sources +- [Toast POS Problems & Complaints (2026) — Sleft](https://www.sleftpayments.com/learning-hub/toast-pos-problems-complaints-2026) +- [Square for Restaurants Review — NerdWallet](https://www.nerdwallet.com/business/software/reviews/square-for-restaurants) +- [What Restaurants Want from POS Systems in 2025 — Access Computech](https://accesscomputech.com/blog/what-restaurants-want-from-pos-systems-in-2025.html) +- [Top POS features for restaurants — Lavu](https://lavu.com/3-features-to-look-for-in-restaurant-pos-software/) +- [44 Best Restaurant POS Features — Quantic](https://getquantic.com/restaurant-pos-system-features/) +- [POS Offline Mode: Why Your Restaurant Needs It — SpotOn](https://www.spoton.com/blog/pos-offline-mode/) +- [Restaurant POS Offline Mode — Clopos](https://www.clopos.com/en/blog/restaurant-pos-offline-mode) +- [Shopify POS in a cafe — modifiers/disconnects (community thread)](https://community.shopify.com/t/experiencing-difficulties-with-shopify-pos-in-a-cafe-setting-any-advice/35154/18) From ecb01b102abe507faf04d902624b411c9fd1b0b4 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 23:48:45 -0500 Subject: [PATCH 15/19] Gap #1: conditional / nested modifiers (the most-cited menu-build frustration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modifier option or add-on can carry a `reveal` group that appears only once it's chosen — e.g. Bubba "Extra cheese" → a required "Which cheese" — and it gates Add until answered, nests (a revealed option can reveal again), and prunes its choice if the trigger is deselected. Answers the #1 menu-build complaint (Toast: "complex/ conditional modifier trees are hard"). - pos.html: revealedGroups(item) expands base modifiers + checked-addon reveals + chosen-option reveals; configPanel renders/validates the active set and only passes choices for still-shown groups to addLine. - builder.html: Bubba's "Extra cheese" now reveals a required cheese choice. - tests/nested-modifiers.js: hidden→shown, gates Add, rides onto the line. Verified live. Co-Authored-By: Claude Opus 4.8 --- builder.html | 2 +- pos.html | 16 ++++++++++--- tests/nested-modifiers.js | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/nested-modifiers.js diff --git a/builder.html b/builder.html index 1fdde74..17ddfe7 100644 --- a/builder.html +++ b/builder.html @@ -354,7 +354,7 @@ // ---- BURGERS → grill (all can "make it a combo") ---- {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"], modifiers:[{group:"Patty",required:true,options:[{name:"Single",price:0},{name:"Double",price:3}]}], - addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}], + addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1,reveal:{group:"Which cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}], flags:["Allergy note"]}, {id:"bachick",name:"Bacon Hickory Burger",price:12.5,category:"burger",path:["grill"],addons:[{name:"Extra bacon",price:1.5},{name:"Make it a combo — fries & drink",price:3.5}]}, {id:"chiliburg",name:"Chili Burger",price:11.5,category:"burger",path:["grill"],addons:[{name:"Extra cheese",price:1},{name:"Make it a combo — fries & drink",price:3.5}]}, diff --git a/pos.html b/pos.html index 9db65c2..7978722 100644 --- a/pos.html +++ b/pos.html @@ -2165,10 +2165,18 @@ } } +// 🔀 conditional (nested) modifiers: a modifier option or an add-on can carry a `reveal` group that only appears +// once it's chosen (e.g. "Add cheese" → "Which cheese?"). Groups expand across the list, so reveals can nest. +function revealedGroups(item){ + const out=[...(item.modifiers||[])]; + (item.addons||[]).forEach(a=>{ if(a.reveal && pending.addons.some(x=>x.name===a.name) && out.indexOf(a.reveal)<0) out.push(a.reveal); }); + for(let i=0;i"; - (item.modifiers||[]).forEach((g,gi)=>{ + revealedGroups(item).forEach((g,gi)=>{ const wrap=document.createElement("div"); wrap.className="mgroup"; wrap.innerHTML="
"+g.group+(g.required?" (required)":"")+"
"; const opts=document.createElement("div"); opts.className="opts"; @@ -2196,9 +2204,11 @@ (FLOW.performers||[]).forEach(name=>{ const b=document.createElement("button"); if(pending.performer===name) b.className="sel"; b.textContent=name; b.onclick=()=>{ pending.performer=name; render(); }; opts.appendChild(b); }); wrap.appendChild(opts); c.appendChild(wrap); } - const reqMet=(item.modifiers||[]).every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer); + const active=revealedGroups(item); + const reqMet=active.every(g=>!g.required||pending.choices[g.group]) && (!item.performer || pending.performer); const add=document.createElement("button"); add.className="primary"; add.textContent="Add to order"; add.disabled=!reqMet; add.style.marginRight="8px"; - add.onclick=()=>{ addLine(item, pending.choices, pending.addons, pending.flags, pending.performer); pending=null; render(); }; + add.onclick=()=>{ const choices={}; active.forEach(g=>{ if(pending.choices[g.group]) choices[g.group]=pending.choices[g.group]; }); // only choices for groups still shown (a deselected reveal drops its choice) + addLine(item, choices, pending.addons, pending.flags, pending.performer); pending=null; render(); }; const cancel=document.createElement("button"); cancel.textContent="Cancel"; cancel.onclick=()=>{ pending=null; render(); }; c.appendChild(add); c.appendChild(cancel); return c; diff --git a/tests/nested-modifiers.js b/tests/nested-modifiers.js new file mode 100644 index 0000000..937b5af --- /dev/null +++ b/tests/nested-modifiers.js @@ -0,0 +1,49 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🔀 CONDITIONAL / NESTED MODIFIERS: a modifier option or add-on can `reveal` a follow-up group that only shows +// once it's chosen (Bubba "Extra cheese" → required "Which cheese"), gates Add until answered, and rides onto the line. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(180); + const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(120); + const hiddenBefore=!/Which cheese/.test(doc().body.innerText); + cB(/^Single/); cB(/Extra cheese/); await wait(120); + const shownAfter=/Which cheese/.test(doc().body.innerText); + const addBtn=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent)); + const gatedUntilAnswered=!!(addBtn && addBtn.disabled); + cB(/^Swiss/); await wait(100); + const addBtn2=[...doc().querySelectorAll('button')].find(x=>/Add to order/.test(x.textContent)); + const enabledAfter=!!(addBtn2 && !addBtn2.disabled); + cB(/Add to order/); await wait(100); + const line=[...doc().querySelectorAll('.line')].map(l=>l.textContent).find(x=>/Bubba/.test(x))||""; + return { hiddenBefore, shownAfter, gatedUntilAnswered, enabledAfter, lineHasCheese:/Swiss/.test(line) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the revealed group is hidden until its trigger is chosen:', out.hiddenBefore && out.shownAfter); + console.log('a required reveal gates "Add" until answered:', out.gatedUntilAnswered && out.enabledAfter); + console.log('the nested choice rides onto the line:', out.lineHasCheese); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.hiddenBefore && out.shownAfter && out.gatedUntilAnswered && out.enabledAfter && out.lineHasCheese && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 01e0e239041a425528fa86d36db9e4359e90b878 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 23:51:20 -0500 Subject: [PATCH 16/19] Gap #3: suggested add-on / upsell prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a main is on the ticket, a "💡 Want to add…" row surfaces one-tap buttons for items it `suggest`s that aren't already ordered (and aren't 86'd / off-hours) — answering the "no upsell suggestions" complaint (Square/Toast). Bubba suggests Fries / Milkshake / Fountain Drink; tapping adds and drops it from the list. - pos.html: suggestions block in renderIntake (reads draft items' `suggest`). - builder.html: Bubba gets suggest:[fries,shake,drink]. - tests/upsell.js. Verified live. Co-Authored-By: Claude Opus 4.8 --- builder.html | 2 +- pos.html | 11 +++++++++++ tests/upsell.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/upsell.js diff --git a/builder.html b/builder.html index 17ddfe7..4da2820 100644 --- a/builder.html +++ b/builder.html @@ -352,7 +352,7 @@ catLabels:{ burger:"Burgers", chicken:"Chicken", sandwich:"Sandwiches", plate:"Plates", texmex:"Tex-Mex", app:"Appetizers", salad:"Salads", soup:"Soups & Potatoes", side:"Sides", shake:"Shakes", dessert:"Desserts", kids:"Kids", drink:"Drinks" }, catalog:[ // ---- BURGERS → grill (all can "make it a combo") ---- - {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"], + {id:"bubba",name:"Bubba Burger",price:13,category:"burger",path:["grill"],suggest:["fries","shake","drink"], modifiers:[{group:"Patty",required:true,options:[{name:"Single",price:0},{name:"Double",price:3}]}], addons:[{name:"Bacon",price:1.5},{name:"Extra cheese",price:1,reveal:{group:"Which cheese",required:true,options:[{name:"American",price:0},{name:"Swiss",price:0},{name:"Cheddar",price:0},{name:"Pepper Jack",price:0}]}},{name:"Jalapeños",price:0.5},{name:"Grilled onions",price:0.5},{name:"Make it a combo — fries & drink",price:3.5}], flags:["Allergy note"]}, diff --git a/pos.html b/pos.html index 7978722..a9c4897 100644 --- a/pos.html +++ b/pos.html @@ -2126,6 +2126,17 @@ c.appendChild(row); }); const tot=document.createElement("div"); tot.className="line"; tot.innerHTML="Total"+money(draft.lines.reduce((s,l)=>s+l.price*l.qty,0))+""; c.appendChild(tot); } app.appendChild(c); + // 💡 suggested add-ons (upsell): items referenced by draft items' `suggest`, not already on the ticket — one tap adds + if(draft.lines.length && station.view.money){ + const want={}; draft.lines.forEach(l=>{ const it=FLOW.catalog.find(x=>x.id===l.catId); (it&&it.suggest||[]).forEach(id=>{ want[id]=1; }); }); + const have={}; draft.lines.forEach(l=>{ have[l.catId]=1; }); + const sugg=Object.keys(want).map(id=>FLOW.catalog.find(x=>x.id===id)).filter(it=>it && !have[it.id] && !is86(it) && !isOutOfStock(it) && itemAvailNow(it)); + if(sugg.length){ const sc=document.createElement("div"); sc.className="card"; sc.style.cssText="padding:8px 10px"; + const lbl=document.createElement("div"); lbl.className="lbl"; lbl.textContent="💡 Want to add…"; sc.appendChild(lbl); + const opts=document.createElement("div"); opts.className="opts"; opts.style.cssText="display:flex;flex-wrap:wrap;gap:6px"; + sugg.forEach(it=>{ const b=document.createElement("button"); b.textContent="+ "+it.name+" "+money(itemPriceNow(it)); b.onclick=()=>addItem(it); opts.appendChild(b); }); + sc.appendChild(opts); app.appendChild(sc); } + } // deposit control (deposit-balance payment point) const dep = FLOW.endpoints.payment.deposit; if(dep && station.view.money && draft.lines.length){ diff --git a/tests/upsell.js b/tests/upsell.js new file mode 100644 index 0000000..4a97c82 --- /dev/null +++ b/tests/upsell.js @@ -0,0 +1,44 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💡 SUGGESTED ADD-ON / UPSELL: an item's `suggest` list surfaces one-tap add buttons for complementary items +// not already on the ticket (Bubba → Fries / Milkshake / Drink). Tapping adds it and drops it from the suggestions. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Hamburger Barn' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Front Counter/); await wait(180); + const bubba=[...doc().querySelectorAll('.tile')].find(x=>/Bubba Burger/.test(x.textContent)); if(bubba)bubba.click(); await wait(100); + cB(/^Single/); cB(/Add to order/); await wait(120); + const shown=/Want to add/.test(doc().body.innerText); + const btns=[...doc().querySelectorAll('button')].map(b=>b.textContent.trim()).filter(x=>x.startsWith('+ ')&&/Fries|Milkshake|Fountain Drink/.test(x)); + const fb=[...doc().querySelectorAll('button')].find(x=>x.textContent.trim().startsWith('+ Fries')); if(fb)fb.click(); await wait(120); + const friesAdded=[...doc().querySelectorAll('.line')].some(l=>/Fries/.test(l.textContent)); + const friesGone=![...doc().querySelectorAll('button')].some(x=>x.textContent.trim().startsWith('+ Fries')); + return { shown, btnCount:btns.length, friesAdded, friesGone }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('adding a main surfaces its suggested add-ons (Fries/Milkshake/Drink):', out.shown && out.btnCount===3); + console.log('tapping a suggestion adds it and drops it from the list:', out.friesAdded && out.friesGone); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.shown && out.btnCount===3 && out.friesAdded && out.friesGone && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 477bf0cdbf214d7724532165dfdc13e8df36bf71 Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 23:56:00 -0500 Subject: [PATCH 17/19] Gaps #2/#4: surface comps in the daily close (report already had waste + the rest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The research called for a daily-close report + wastage tracking. The report already has net/tax/tips/tenders/cash-drawer/COGS-margin and stock adjustments incl. Waste (adjustStock "Waste" → adjustLog, shown per day) — so those were substantially present. The real gap was comp visibility: the report now shows a "Comps (on the house)" line so an owner sees what was given away at close. - pos.html: sum l.comped.amount across the day; "Comps (on the house)" summary line. - tests/report-comps.js: a paid order with a $20 comped line shows the comps line. - docs/MARKET-GRIEVANCES.md: progress — #1 nested mods, #2 close/comps, #3 upsell, #4 waste all done; #5 driver report + #6 tip-out remain. Verified live: Net $30 · Comps −$20 · Total $30. Co-Authored-By: Claude Opus 4.8 --- docs/MARKET-GRIEVANCES.md | 15 ++++++------- pos.html | 5 +++-- tests/report-comps.js | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 tests/report-comps.js diff --git a/docs/MARKET-GRIEVANCES.md b/docs/MARKET-GRIEVANCES.md index d578553..f6d257c 100644 --- a/docs/MARKET-GRIEVANCES.md +++ b/docs/MARKET-GRIEVANCES.md @@ -46,13 +46,14 @@ by seat, comps, server assignment, transfer/merge, day-parts/happy-hour, reserva and the moat: **say a grievance in plain English → it's changed**, which is the exact opposite of "escalation takes days." -## New gaps worth building (prioritized from the research) -1. **Nested / conditional modifiers** — the most-cited menu-build frustration. -2. **One-screen daily close report** + clean tip / tip-out export (payroll pain). -3. **Suggested add-on / upsell prompt** — wanted, and cheap on top of combos. -4. **Wastage / waste-log** in inventory — the inventory depth operators miss. -5. **Delivery-driver report** — small, concrete, repeatedly requested. -6. **Tip-out by role + tip attribution on transfer** — bartender/server pain. +## New gaps worth building (prioritized from the research) — progress +1. ✅ **Nested / conditional modifiers** — shipped (`nested-modifiers.js`). +2. ✅ **Daily close visibility** — the report already had net/tax/tips/tenders/drawer/COGS/**waste**; added a + **Comps** line so what's given away shows at close (`report-comps.js`). +3. ✅ **Suggested add-on / upsell prompt** — shipped (`upsell.js`). +4. ✅ **Wastage** — already tracked (`adjustStock` "Waste" reason → `adjustLog`, shown in the report); no rebuild needed. +5. ⬜ **Delivery-driver report** — small; low priority for a dine-in burger joint (revisit if a delivery line matters). +6. ⬜ **Tip-out by role + tip attribution on transfer** — bartender/server pain; a good next build. These are recommendations, not commitments — the owner prioritizes (and real Burger Barn grievances outrank a forum). Each ships through the safety pipeline with a test, like everything here. diff --git a/pos.html b/pos.html index a9c4897..e365099 100644 --- a/pos.html +++ b/pos.html @@ -940,7 +940,7 @@ const done = (storesOn() && reportStore) ? dayAll.filter(r=>r.storeId===reportStore) : dayAll; const L=(lab,val,bold)=>"
"+(bold?"":"")+lab+(bold?"":"")+""+(bold?"":"")+val+(bold?"":"")+"
"; // ---- daily sales summary (refund-aware: a refunded order's sale nets out, its reversed tender nets its tenders) ---- - let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0; const byTender={}, byCat={}, byItem={}, byHour={}; + let net=0, tax=0, tips=0, disc=0, items=0, refunds=0, orders=0, cogs=0, roundUp=0, comps=0; const byTender={}, byCat={}, byItem={}, byHour={}; const itemCost=(l)=>{ const it=FLOW.catalog.find(i=>i.id===l.catId); return (it&&+it.cost)||0; }; done.forEach(r=>{ const rf=r.status==="REFUNDED"; if(rf){ refunds += recordTotal(r); } @@ -949,6 +949,7 @@ net += recordNet(r)*frac; tax += recordTax(r)*frac; tips += r.tip||0; disc += recordDiscount(r)*frac; roundUp += r.roundUp||0; if(r.ts){ const hr=new Date(r.ts).getHours(); byHour[hr]=(byHour[hr]||0)+recordNet(r)*frac; } r.lines.filter(l=>!l.returned).forEach(l=>{ items += l.qty; cogs += itemCost(l)*l.qty; byCat[l.category||"other"]=(byCat[l.category||"other"]||0)+l.price*l.qty; + if(l.comped) comps += (l.comped.amount||0); // 🎁 value given away as comps today const bi=byItem[l.name]=byItem[l.name]||{qty:0,rev:0,cost:0}; bi.qty+=l.qty; bi.rev+=l.price*l.qty; bi.cost+=itemCost(l)*l.qty; }); const ret = -((r.tenders||[]).filter(t=>t.refund).reduce((s,t)=>s+t.amount,0)); if(ret>0.0001) refunds += ret; // partial line returns } @@ -974,7 +975,7 @@ const sum=document.createElement("div"); sum.className="card"; sum.innerHTML="
Sales summary
" + L("Orders", String(orders)) + L("Items sold", String(items)) - + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"") + + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + (comps?L("Comps (on the house)","−"+money(comps)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"") + L("Total collected", money(collected), true) + (onAccount?L("On account (A/R)", money(onAccount)):""); app.appendChild(sum); // ---- community: charity round-ups collected today (only when the round-up option is on) ---- diff --git a/tests/report-comps.js b/tests/report-comps.js new file mode 100644 index 0000000..a2b1d83 --- /dev/null +++ b/tests/report-comps.js @@ -0,0 +1,44 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 🧾 DAILY CLOSE surfaces COMPS. The report already shows net/tax/tips/tenders/drawer/COGS/waste; this pins that +// the value given away as comps shows as its own line so the owner sees it at close. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + // a PAID order today: $30 sold + a $20 comped line + w.DB=w.loadDB(); + w.DB.records=[{id:'RP',number:1,status:'PAID',ts:Date.now(),tenders:[{type:'cash',amount:30}], + lines:[{id:'a',name:'Steak',price:30,qty:1,category:'entree'},{id:'b',name:'Wine',price:0,qty:1,category:'drink',comped:{reason:'Kitchen error',amount:20,wasPrice:20}}]}]; + w.saveDB(w.DB); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Office/); await wait(250); + const txt=doc().body.innerText; + return { onReport:/Sales summary/.test(txt), net:/Net sales/.test(txt), comps:/Comps \(on the house\)/.test(txt), amt:/20\.00/.test(txt) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the daily close renders a sales summary:', out.onReport && out.net); + console.log('comps show as their own line with the amount given away:', out.comps && out.amt); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.onReport && out.net && out.comps && out.amt && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From 03706bc78c5cdacff675b6c71d04a5f6dd5ba04a Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sat, 12 Sep 2026 23:58:06 -0500 Subject: [PATCH 18/19] Gap #6: tip-out by role in the daily close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tip.tipOut=[{role,pct}] is configured, the report spells out each support role's cut of today's tips (a % of tips) and what the servers keep — answering the "tips in payroll is painful / tip-out done by hand" complaint. Informational at close; no money moves automatically. - pos.html: tip-out card in the report (each role's cut of `tips`, remainder kept). - builder.html: bistro tipOut [Kitchen 5%, Bussers 10%]. - tests/tip-out.js: $100 tips → Kitchen $5, Bussers $10, servers keep $85. Verified live. That closes the research gap list except the delivery-driver report (low priority for a dine-in burger joint). Co-Authored-By: Claude Opus 4.8 --- builder.html | 2 +- docs/MARKET-GRIEVANCES.md | 2 +- pos.html | 9 ++++++++ tests/tip-out.js | 43 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/tip-out.js diff --git a/builder.html b/builder.html index 4da2820..a385a36 100644 --- a/builder.html +++ b/builder.html @@ -444,7 +444,7 @@ bistro: { flowId:"bistro", label:"Full-service restaurant", topology:"hub-and-spoke", blurb:"Dine-in — tables, floor plan, kitchen & bar", branding:{ name:"My Bistro", brandColor:"#7a1f2b" }, staff:[ {id:"own",name:"Owner",pin:"1111",role:"owner"}, {id:"mgr",name:"Manager",pin:"2222",role:"manager"}, {id:"srv1",name:"Riley",pin:"3333"}, {id:"srv2",name:"Sam",pin:"4444"} ], // servers own tables; owner/manager approve comps - endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, orderTypes:["Dine-in","To-go"], coursing:true }, // happy hour 3–6pm + endpoints:{ customer:{persist:false}, payment:{tenders:["cash","card"], closeGate:"balanceLE0", tip:{presets:[18,20,22], tipOut:[{role:"Kitchen",pct:5},{role:"Bussers",pct:10}]}, split:true}, approvals:{comp:true,refund:true}, happyHour:{from:"15:00", to:"18:00"}, orderTypes:["Dine-in","To-go"], coursing:true }, // happy hour 3–6pm courses:["Appetizers","Entrées","Desserts"], timer:{ mode:"aging", warnSec:600, badSec:1200, label:"ticket" }, kds:{warnSec:420,badSec:900}, floor:{ label:"Dining Room", warnSec:900, sections:["Main","Patio","Bar"], tables:[ diff --git a/docs/MARKET-GRIEVANCES.md b/docs/MARKET-GRIEVANCES.md index f6d257c..93700b1 100644 --- a/docs/MARKET-GRIEVANCES.md +++ b/docs/MARKET-GRIEVANCES.md @@ -53,7 +53,7 @@ takes days." 3. ✅ **Suggested add-on / upsell prompt** — shipped (`upsell.js`). 4. ✅ **Wastage** — already tracked (`adjustStock` "Waste" reason → `adjustLog`, shown in the report); no rebuild needed. 5. ⬜ **Delivery-driver report** — small; low priority for a dine-in burger joint (revisit if a delivery line matters). -6. ⬜ **Tip-out by role + tip attribution on transfer** — bartender/server pain; a good next build. +6. ✅ **Tip-out by role** — shipped: the close spells out each support role's cut of tips + what servers keep (`tip-out.js`). These are recommendations, not commitments — the owner prioritizes (and real Burger Barn grievances outrank a forum). Each ships through the safety pipeline with a test, like everything here. diff --git a/pos.html b/pos.html index e365099..c902d81 100644 --- a/pos.html +++ b/pos.html @@ -978,6 +978,15 @@ + L("Net sales", money(net)) + (disc?L("Discounts","−"+money(disc)):"") + (comps?L("Comps (on the house)","−"+money(comps)):"") + L("Tax collected", money(tax)) + (tips?L("Tips", money(tips)):"") + (refunds?L("Refunds","−"+money(refunds)):"") + L("Total collected", money(collected), true) + (onAccount?L("On account (A/R)", money(onAccount)):""); app.appendChild(sum); + // 💸 tip-out by role — the payroll pain point. When configured, show each support role's cut of today's tips + // (a % of tips) and what's left for the servers, so tip-out is spelled out at close instead of done by hand. + const tipOut=(FLOW.endpoints.payment.tip&&FLOW.endpoints.payment.tip.tipOut)||null; + if(tipOut && tipOut.length && tips>0.0001){ + let out=0; const rows=tipOut.map(t=>{ const cut=+(tips*(+t.pct||0)/100).toFixed(2); out+=cut; return L("→ "+(t.role||"support")+" ("+(+t.pct||0)+"%)","−"+money(cut)); }).join(""); + const toCard=document.createElement("div"); toCard.className="card"; + toCard.innerHTML="
Tip-out from "+money(tips)+" in tips
"+rows+L("Kept by servers", money(+(tips-out).toFixed(2)), true); + app.appendChild(toCard); + } // ---- community: charity round-ups collected today (only when the round-up option is on) ---- if(FLOW.endpoints.payment.roundUp && roundUp>0){ const cause=(FLOW.endpoints.payment.roundUp.cause)||"a local cause"; const cc=document.createElement("div"); cc.className="card"; cc.innerHTML="
Community ♥
"+L("Rounded up for "+cause, money(roundUp), true)+"
Collected from customers who chose to round up — set it aside to donate.
"; diff --git a/tests/tip-out.js b/tests/tip-out.js new file mode 100644 index 0000000..a749859 --- /dev/null +++ b/tests/tip-out.js @@ -0,0 +1,43 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 💸 TIP-OUT BY ROLE at close: when configured (tip.tipOut=[{role,pct}]), the report spells out each support +// role's cut of today's tips and what the servers keep — instead of doing it by hand for payroll. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(async () => { + const doc=()=>document.querySelector('#preview').contentDocument; + const w=document.querySelector('#preview').contentWindow; + const wait=ms=>new Promise(r=>setTimeout(r,ms)); + w.DB=w.loadDB(); + w.DB.records=[{id:'RT',number:1,status:'PAID',ts:Date.now(),tip:100,tenders:[{type:'card',amount:200}],lines:[{id:'a',name:'Steak',price:100,qty:1,category:'entree'}]}]; + w.saveDB(w.DB); + const cB=re=>{const b=[...doc().querySelectorAll('button')].find(x=>re.test(x.textContent.trim()));if(b){b.click();return true;}return false;}; + cB(/^Got it/); cB(/^Office/); await wait(280); + const txt=doc().body.innerText; + return { shown:/Tip-out/.test(txt), kitchen:/Kitchen \(5%\)/.test(txt)&&/5\.00/.test(txt), bussers:/Bussers \(10%\)/.test(txt)&&/10\.00/.test(txt), kept:/Kept by servers/.test(txt)&&/85\.00/.test(txt) }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('the daily close shows a tip-out breakdown:', out.shown); + console.log('each role gets its % of tips (Kitchen 5%=$5, Bussers 10%=$10):', out.kitchen && out.bussers); + console.log('servers keep the remainder ($85 of $100):', out.kept); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.shown && out.kitchen && out.bussers && out.kept && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); From fd5ebe2b88d638b55d22e4237d81741329b5e7fc Mon Sep 17 00:00:00 2001 From: Brayden Ozark Date: Sun, 13 Sep 2026 00:02:33 -0500 Subject: [PATCH 19/19] Beyond the gap list: per-seat receipt (companion to split-by-seat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an order was rung by seat, its receipt now groups items under each seat (+ Shared) with per-seat subtotals — the itemized companion to paying by seat. Non-seated orders stay a flat receipt. - pos.html: receiptPanel groups by seatBuckets when orderHasSeats(r). - tests/seat-receipt.js. - docs/MARKET-GRIEVANCES.md: parked further ideas (day-part manager override, void-with-reason, cash-tip declaration, driver report) for owner steer — these are judgment calls better set by real Burger Barn shifts than a forum. Verified live: seated receipt groups Seat 1 / Seat 2 / Shared; flat otherwise. Co-Authored-By: Claude Opus 4.8 --- docs/MARKET-GRIEVANCES.md | 9 ++++++++ pos.html | 8 +++++++- tests/seat-receipt.js | 43 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/seat-receipt.js diff --git a/docs/MARKET-GRIEVANCES.md b/docs/MARKET-GRIEVANCES.md index 93700b1..7177e1b 100644 --- a/docs/MARKET-GRIEVANCES.md +++ b/docs/MARKET-GRIEVANCES.md @@ -55,6 +55,15 @@ takes days." 5. ⬜ **Delivery-driver report** — small; low priority for a dine-in burger joint (revisit if a delivery line matters). 6. ✅ **Tip-out by role** — shipped: the close spells out each support role's cut of tips + what servers keep (`tip-out.js`). +**Beyond the forum list (ideas generated + shipped):** +- ✅ **Per-seat receipt** — a seated order's receipt groups items by seat with subtotals (`seat-receipt.js`). + +**Further ideas parked for owner steer (judgment calls better set by real Burger Barn shifts than by a forum):** +- Manager-PIN override for the day-part gate (rare off-hours exception, logged) — *note the honesty tradeoff; owner's call.* +- Void-with-reason on a sent-but-unpaid item (overlaps comp; loss-prevention angle). +- Cash-tip declaration at close (payroll). +- Delivery-driver report (only if a delivery line matters). + These are recommendations, not commitments — the owner prioritizes (and real Burger Barn grievances outrank a forum). Each ships through the safety pipeline with a test, like everything here. diff --git a/pos.html b/pos.html index c902d81..6dd9d14 100644 --- a/pos.html +++ b/pos.html @@ -2457,7 +2457,13 @@ function receiptPanel(r){ const sub=lineSubtotal(r), disc=recordDiscount(r), tax=recordTax(r), tip=r.tip||0, tot=recordTotal(r); const line=(l,v,b)=>"
"+(b?"":"")+l+(b?"":"")+""+(b?"":"")+v+(b?"":"")+"
"; - const rows=r.lines.map(l=>"
"+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
").join(""); + const lineRow=l=>"
"+l.qty+"× "+l.name+(l.modLabel?" ("+l.modLabel+")":"")+(l.serialized&&l.tag?" ["+(l.tagLabel||"Tag")+" "+l.tag+"]":"")+""+money(l.price*l.qty)+"
"; + // 👤 per-seat receipt: when the order was rung by seat, group the itemized lines under each seat with its subtotal + let rows; + if(orderHasSeats(r)){ + rows=seatBuckets(r).map(bkt=>{ const its=r.lines.filter(l=>((l.seat?("s"+l.seat):"shared"))===bkt.key); + return "
"+bkt.label+""+money(bkt.sub)+"
"+its.map(lineRow).join(""); }).join(""); + } else { rows=r.lines.map(lineRow).join(""); } const hr="
"; const totals=(disc||tax?line("Subtotal",money(sub)):"")+(disc?line("Discount","−"+money(disc)):"")+(tax?line(taxIncluded()?"Tax (incl.)":"Tax",money(tax)):"")+(tip?line("Tip",money(tip)):"")+line("Total",money(tot),true); const tenders=(r.tenders||[]).map(t=>line(t.type==="card"?cardLabel(t):t.type[0].toUpperCase()+t.type.slice(1), money(t.amount))).join(""); diff --git a/tests/seat-receipt.js b/tests/seat-receipt.js new file mode 100644 index 0000000..6cee29f --- /dev/null +++ b/tests/seat-receipt.js @@ -0,0 +1,43 @@ +const { chromium } = require('playwright-core'); +const http = require('http'), fs = require('fs'), path = require('path'); +const EXE = process.env.CHROMIUM_EXE || '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = path.resolve(__dirname, '..'); +const TYPES = { '.html':'text/html', '.js':'text/javascript' }; +function serve(){ return new Promise(r=>{ const s=http.createServer((rq,rs)=>{ if(rq.url==='/favicon.ico'){rs.statusCode=204;return rs.end();} const f=path.join(ROOT,rq.url.split('?')[0]); fs.readFile(f,(e,b)=>{ if(e){rs.statusCode=404;return rs.end('nf');} rs.setHeader('Content-Type',TYPES[path.extname(f)]||'text/plain'); rs.end(b); }); }); s.listen(0,'127.0.0.1',()=>r(s)); }); } + +// 👤🧾 PER-SEAT RECEIPT: when an order was rung by seat, the receipt groups its items under each seat (+ Shared) +// with per-seat subtotals — the itemized companion to split-by-seat. +(async () => { + const errors = []; + const server = await serve(); const port = server.address().port; + const b = await chromium.launch({ executablePath: EXE, args:['--no-sandbox'] }); + const ctx = await b.newContext(); const p = await ctx.newPage(); + p.on('console', m => { if (m.type()==='error') errors.push(m.text()); }); + p.on('pageerror', e => errors.push('pageerror: '+e.message)); + await p.goto(`http://127.0.0.1:${port}/builder.html`); + await p.locator('button.trade').filter({ hasText: 'Full-service' }).click(); + await p.getByRole('button',{name:/Build it for me/}).click(); + await p.waitForFunction(() => window.__build && window.__build.html); + + const out = await p.evaluate(() => { + const w=document.querySelector('#preview').contentWindow; + const seated={ id:'RR', number:9, status:'READY', tenders:[], lines:[ {id:'a',name:'Calamari',price:12,qty:1,seat:1}, {id:'b',name:'Ribeye',price:34,qty:1,seat:2}, {id:'c',name:'Bread',price:6,qty:1,seat:null} ] }; + const seatedHtml=w.receiptPanel(seated).innerHTML; + const flat={ id:'RF', number:10, status:'READY', tenders:[], lines:[ {id:'x',name:'Burger',price:9,qty:1} ] }; + const flatHtml=w.receiptPanel(flat).innerHTML; + return { + grouped: /Seat 1/.test(seatedHtml) && /Seat 2/.test(seatedHtml) && /Shared/.test(seatedHtml) && /Calamari/.test(seatedHtml) && /Ribeye/.test(seatedHtml), + total: /Total/.test(seatedHtml), + flatUngrouped: !/Seat 1/.test(flatHtml) && /Burger/.test(flatHtml) + }; + }); + + await b.close(); server.close(); + console.log('\n=== RESULTS ==='); + console.log('a seated order receipt groups items by seat (+ Shared) with subtotals:', out.grouped); + console.log('it still shows the order total:', out.total); + console.log('a non-seated order receipt stays a flat list:', out.flatUngrouped); + console.log('console errors:', errors.length?errors:'NONE'); + const ok = out.grouped && out.total && out.flatUngrouped && !errors.length; + process.exit(ok?0:1); +})().catch(e => { console.error('FATAL', e); process.exit(2); });