Show live order progress, delays, and kitchen cancellations

- Order cards derive their label from item progress (Not Started,
  Preparing, Partly Ready, Ready, Picked Up, Cancelled) instead of the
  order's own status; final states are coloured
- Order page: animated status banner per item (ready/picked up check,
  cancelled cross, running-late clock), steps collapse once an item is
  settled with a Show/Hide steps toggle, done steps are faded instead
  of struck through, and the per-item status badge is gone
- New Delay concept: machines can report extra time on a claimed step
  (preparation_step_instances.delay_seconds), which counts toward the ETA
- The kitchen can cancel an item at any point before it's ready, with a
  refund (order_items.cancelled_by records who cancelled)
- Dev kitchen simulation now runs server-side across every order, so a
  step nobody is watching no longer blocks the queue; each settled step
  may randomly be delayed or cancelled
- Humanize snake_case statuses in the UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:11:08 +02:00
co-authored by Claude Opus 5.5
parent c124b0415a
commit a6ac32d783
17 changed files with 1651 additions and 111 deletions
+150 -6
View File
@@ -132,7 +132,7 @@ describe('menu', () => {
])
})
it('lists each menu item\'s customization Options', async () => {
it("lists each menu item's customization Options", async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
const pizza = menu.find((item) => item.name === 'Pizza')
@@ -268,7 +268,7 @@ describe('placing an order', () => {
})
describe('customizing an item', () => {
it("charges the adjusted price for selected Options and snapshots them on the Order Item", async () => {
it('charges the adjusted price for selected Options and snapshots them on the Order Item', async () => {
const extraCheese = seed.menuItemOptions.pizza.find(
(option) => option.name === 'Extra cheese',
)!
@@ -390,6 +390,7 @@ describe('cancelling an Order Item', () => {
(item) => item.menuItemName === 'Burger',
)!
expect(cancelledPizza.status).toBe('cancelled')
expect(cancelledPizza.cancelledBy).toBe('customer')
expect(untouchedBurger.steps[0].status).toBe('in_progress')
})
@@ -608,6 +609,96 @@ describe('advancing steps and pickup', () => {
})
})
describe('kitchen simulation', () => {
const policy = (
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
): ordering.KitchenSimulationPolicy => ({
stepDurationMs: (step) => (60 + step.delaySeconds) * 1000,
pickOutcome: () => outcome,
})
const inAMinute = () => new Date(Date.now() + 60_000)
it("finishes elapsed steps across every Order, unblocking an item queued behind someone else's", async () => {
const blockingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'queued',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
inAMinute(),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'done',
)
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
it('keeps a delayed step in progress and adds the delay to the ETA', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const before = (await getStatus(order)).items[0]
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'delay', delaySeconds: 180 }),
inAMinute(),
)
const after = (await getStatus(order)).items[0]
expect(after.steps[0].status).toBe('in_progress')
expect(after.delaySeconds).toBe(180)
expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170)
})
it('lets the kitchen cancel an item mid-step, refunding it and freeing its machine', async () => {
const cancelledOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'cancel' }),
inAMinute(),
)
const cancelledItem = (await getStatus(cancelledOrder)).items[0]
expect(cancelledItem.status).toBe('cancelled')
expect(cancelledItem.cancelledBy).toBe('kitchen')
expect(payment.refundsFor(cancelledOrder.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect((await getStatus(cancelledOrder)).status).toBe('complete')
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
})
describe('recipe snapshot', () => {
it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => {
const order = await placeGuestOrder([
@@ -755,10 +846,9 @@ describe('order history', () => {
{ menuItemId: seed.menuItems.burger.id },
])
const withCorrectToken = await ordering.getGuestOrderSummaries(
testDb.db,
[{ orderId: order.id, guestAccessToken: order.guestAccessToken! }],
)
const withCorrectToken = await ordering.getGuestOrderSummaries(testDb.db, [
{ orderId: order.id, guestAccessToken: order.guestAccessToken! },
])
expect(withCorrectToken).toHaveLength(1)
expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken)
expect(withCorrectToken[0].items).toEqual([
@@ -770,4 +860,58 @@ describe('order history', () => {
])
expect(withWrongToken).toEqual([])
})
it("reports an Order's progress from its items as they cook and get picked up", async () => {
const order = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
const progress = async () =>
(await ordering.getOrdersForCustomer(testDb.db, 'test-customer-1'))[0]
.progress
expect(await progress()).toBe('preparing')
const access = { customerId: 'test-customer-1' }
const itemId = (await ordering.getOrderStatus(testDb.db, order.id, access))
.items[0].id
await ordering.advanceOrderItemStep(testDb.db, itemId)
await ordering.advanceOrderItemStep(testDb.db, itemId)
expect(await progress()).toBe('ready')
await ordering.markOrderItemPickedUp(testDb.db, itemId, access)
expect(await progress()).toBe('picked_up')
})
it('reports an Order as not started while its only item waits for a busy machine', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }])
await placeAccountOrder(
[{ menuItemId: seed.menuItems.pizza.id }],
'test-customer-1',
)
const [order] = await ordering.getOrdersForCustomer(
testDb.db,
'test-customer-1',
)
expect(order.progress).toBe('not_started')
})
const paid = { status: 'paid', started: true } as const
const waiting = { status: 'paid', started: false } as const
const ready = { status: 'ready', started: true } as const
const pickedUp = { status: 'picked_up', started: true } as const
const cancelled = { status: 'cancelled', started: false } as const
it.each([
[[waiting, waiting], 'not_started'],
[[waiting, cancelled], 'not_started'],
[[waiting, paid], 'preparing'],
[[pickedUp, waiting], 'preparing'],
[[ready, waiting], 'partly_ready'],
[[ready, pickedUp], 'ready'],
[[cancelled, pickedUp], 'picked_up'],
[[cancelled, cancelled], 'cancelled'],
] as const)('derives progress from %j as %s', (items, expected) => {
expect(ordering.getOrderProgress([...items])).toBe(expected)
})
})