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:
+150
-6
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user