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
@@ -0,0 +1,42 @@
@use '../../styles/variables' as *;
.banner {
display: flex;
align-items: center;
gap: $space-2;
color: $color-success;
&[data-variant='cancelled'] {
color: $color-danger;
}
&[data-variant='delayed'] {
color: $color-warning;
}
}
.icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.text {
display: flex;
flex-direction: column;
}
.label {
font-size: 0.9rem;
font-weight: 600;
}
.detail {
font-size: 0.8rem;
color: $color-text-muted;
}
@@ -0,0 +1,69 @@
import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant =
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
delayed: ['M12 7v5', 'M12 12l3.5 2'],
} satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = {
variant: ItemStatusBannerVariant
label: string
detail?: string
animateIn: boolean
}
export const ItemStatusBanner = ({
variant,
label,
detail,
animateIn,
}: ItemStatusBannerProps) => {
const reduceMotion = useReducedMotion()
const skipAnimation = !animateIn || reduceMotion
return (
<motion.div
className={styles.banner}
data-variant={variant}
role="status"
initial={skipAnimation ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<motion.circle
cx="12"
cy="12"
r="10"
initial={skipAnimation ? false : { pathLength: 0, rotate: -90 }}
animate={{ pathLength: 1, rotate: -90 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
{iconPaths[variant].map((d, index) => (
<motion.path
key={d}
d={d}
initial={skipAnimation ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.3,
ease: 'easeOut',
delay: 0.45 + index * 0.2,
}}
/>
))}
</svg>
<span className={styles.text}>
<span className={styles.label}>{label}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</span>
</motion.div>
)
}
@@ -64,7 +64,9 @@
color: $color-text-muted;
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
transition:
border-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
@@ -104,17 +106,42 @@
margin-bottom: $space-2;
}
.itemStatus {
text-transform: capitalize;
color: $color-text-muted;
}
.itemOptions {
margin: 0 0 $space-2;
font-size: 0.85rem;
color: $color-text-muted;
}
// The negative margin + matching padding leaves room for the in-progress
// dot's sonar ping, which the collapse animation's overflow would clip.
.timelineWrapper {
overflow: hidden;
margin: (-$space-2);
padding: $space-2;
}
.bannerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-2;
margin: 0 0 $space-2;
}
.stepsToggle {
padding: 0;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $color-primary;
}
}
.timeline {
margin: 0 0 $space-2;
padding: 0;
@@ -186,6 +213,8 @@
}
.step[data-status='done'] {
opacity: 0.5;
.stepDot {
border-color: $color-success;
background: $color-success;
@@ -193,7 +222,6 @@
.stepLabel {
color: $color-text;
text-decoration: line-through;
}
}
+183 -76
View File
@@ -1,29 +1,82 @@
import { Link, useRouter } from '@tanstack/react-router'
import { motion, useReducedMotion } from 'framer-motion'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { formatEtaMessage } from '#/lib/format.ts'
import { formatDuration, formatEtaMessage } from '#/lib/format.ts'
import {
advanceOrderItemStep,
advanceSimulatedKitchen,
cancelOrderItem,
getOrderStatus,
markOrderItemPickedUp,
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// Compresses a step's real-world estimated duration into something worth
// watching in a dev session, while keeping slower steps visibly slower than
// quick ones rather than flattening every step to the same delay.
const simulatedStepDelayMs = (estimatedDurationSeconds: number) =>
Math.min(Math.max(estimatedDurationSeconds * 50, 10_000), 30_000)
const isSettled = (item: OrderItemStatusView) =>
item.status === 'ready' ||
item.status === 'picked_up' ||
item.status === 'cancelled'
// `animationKey` groups variants that should read as one continuous banner:
// going from ready to picked up swaps the label without replaying the check.
const getItemBanner = (item: OrderItemStatusView) => {
if (item.status === 'ready') {
return {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
} as const
}
if (item.status === 'picked_up') {
return {
animationKey: 'done',
variant: 'pickedUp',
label: 'Picked up',
} as const
}
if (item.status === 'cancelled') {
return item.cancelledBy === 'kitchen'
? ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled by the kitchen',
detail:
"Something went wrong on our end, so we've refunded this item.",
} as const)
: ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled',
detail: "You've been refunded for this item.",
} as const)
}
if (item.delaySeconds > 0) {
return {
animationKey: 'delayed',
variant: 'delayed',
label: 'Running late',
detail: `About ${formatDuration(item.delaySeconds)} behind schedule`,
} as const
}
return null
}
const bannerKey = (item: OrderItemStatusView) => {
const banner = getItemBanner(item)
return banner ? `${item.id}:${banner.animationKey}` : null
}
const cancelBlockedReason = (item: OrderItemStatusView) => {
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
if (
item.cancellable ||
item.status === 'cancelled' ||
item.status === 'picked_up'
) {
return null
}
if (item.status === 'ready') {
@@ -73,7 +126,9 @@ const StepConnector = ({ filled }: { filled: boolean }) => {
className={styles.stepConnectorFill}
initial={false}
animate={{ scaleY: filled ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }
}
/>
</span>
)
@@ -101,8 +156,31 @@ export const OrderStatusView = ({
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [simulating, setSimulating] = useState(false)
// Only banners that appear while the page is open animate in; ones
// already showing on load render straight away.
const [initialBannerKeys] = useState(
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
)
const [expandedItemIds, setExpandedItemIds] = useState<ReadonlySet<number>>(
new Set(),
)
const reduceMotion = useReducedMotion()
const collapseTransition = reduceMotion
? { duration: 0 }
: { duration: 0.4, ease: 'easeInOut' as const }
const runAction = async (orderItemId: number, action: () => Promise<void>) => {
const toggleSteps = (orderItemId: number) => {
setExpandedItemIds((current) => {
const next = new Set(current)
if (!next.delete(orderItemId)) next.add(orderItemId)
return next
})
}
const runAction = async (
orderItemId: number,
action: () => Promise<void>,
) => {
setPendingItemId(orderItemId)
setError(null)
try {
@@ -115,50 +193,25 @@ export const OrderStatusView = ({
}
}
// Plays the part of a Machine actually working each Preparation Step, one
// item at a time, so the timeline advances the same way it would from real
// hardware — just compressed in time. Polls fresh status itself rather than
// trusting the `status` prop, since several items advance concurrently and
// each one's steps move independently of the others.
const simulateOrderItem = async (orderItemId: number) => {
for (;;) {
const fresh = await getOrderStatus({
data: { orderId: status.id, guestAccessToken },
})
const item = fresh.items.find((row) => row.id === orderItemId)
if (
!item ||
item.status === 'ready' ||
item.status === 'picked_up' ||
item.status === 'cancelled'
) {
return
}
const currentStep = item.steps.find((step) => step.status !== 'done')
if (!currentStep) return
if (currentStep.status !== 'in_progress') {
// Queued behind another item on the same Machine Tag — wait for it
// to be assigned rather than failing the whole simulation.
await sleep(400)
continue
}
await sleep(simulatedStepDelayMs(currentStep.estimatedDurationSeconds))
await advanceOrderItemStep({ data: { orderItemId } })
await router.invalidate()
}
}
const runKitchenSimulation = async () => {
setSimulating(true)
setError(null)
try {
const itemIds = status.items
.filter((item) => item.status === 'placed' || item.status === 'paid')
.map((item) => item.id)
await Promise.all(itemIds.map((orderItemId) => simulateOrderItem(orderItemId)))
for (;;) {
await advanceSimulatedKitchen()
const fresh = await getOrderStatus({
data: { orderId: status.id, guestAccessToken },
})
await router.invalidate()
if (
!fresh.items.some(
(item) => item.status === 'placed' || item.status === 'paid',
)
) {
return
}
await sleep(1000)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
@@ -210,42 +263,94 @@ export const OrderStatusView = ({
<ul className={styles.items}>
{status.items.map((item) => {
const cancelHint = cancelBlockedReason(item)
const banner = getItemBanner(item)
return (
<li key={item.id} className={styles.item}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div>
{item.options.length > 0 && (
<p className={styles.itemOptions}>
{item.options.map((option) => option.name).join(', ')}
</p>
)}
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
aria-current={step.status === 'in_progress' ? 'step' : undefined}
<AnimatePresence initial={false}>
{(!isSettled(item) || expandedItemIds.has(item.id)) && (
<motion.div
key="timeline"
id={`steps-${item.id}`}
className={styles.timelineWrapper}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={collapseTransition}
>
<span className={styles.stepMarker} aria-hidden="true">
<span className={styles.stepDot} />
<StepConnector filled={step.status === 'done'} />
</span>
<span className={styles.stepContent}>
<span className={styles.stepLabel}>{step.name}</span>
{step.status === 'queued' && step.queuePosition !== null && (
<span className={styles.stepMeta}>
#{step.queuePosition} in line
</span>
)}
</span>
</li>
))}
</ol>
<ol className={styles.timeline}>
{item.steps.map((step) => (
<li
key={step.sequence}
className={styles.step}
data-status={step.status}
aria-current={
step.status === 'in_progress' ? 'step' : undefined
}
>
<span
className={styles.stepMarker}
aria-hidden="true"
>
<span className={styles.stepDot} />
<StepConnector filled={step.status === 'done'} />
</span>
<span className={styles.stepContent}>
<span className={styles.stepLabel}>
{step.name}
</span>
{step.status === 'queued' &&
step.queuePosition !== null && (
<span className={styles.stepMeta}>
#{step.queuePosition} in line
</span>
)}
</span>
</li>
))}
</ol>
</motion.div>
)}
</AnimatePresence>
{banner && (
<div className={styles.bannerRow}>
<ItemStatusBanner
key={banner.animationKey}
variant={banner.variant}
label={banner.label}
detail={'detail' in banner ? banner.detail : undefined}
animateIn={
!initialBannerKeys.has(
`${item.id}:${banner.animationKey}`,
)
}
/>
{isSettled(item) && (
<button
type="button"
className={styles.stepsToggle}
aria-expanded={expandedItemIds.has(item.id)}
aria-controls={`steps-${item.id}`}
onClick={() => toggleSteps(item.id)}
>
{expandedItemIds.has(item.id)
? 'Hide steps'
: 'Show steps'}
</button>
)}
</div>
)}
{item.etaSeconds !== null && (
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p>
<p className={styles.eta}>
{formatEtaMessage(item.etaSeconds)}
</p>
)}
<div className={styles.actions}>
<button
@@ -299,7 +404,9 @@ export const OrderStatusView = ({
popover="auto"
role="tooltip"
className={styles.cancelHint}
style={{ positionAnchor: `--cancel-hint-anchor-${item.id}` }}
style={{
positionAnchor: `--cancel-hint-anchor-${item.id}`,
}}
>
{cancelHint}
</p>
@@ -132,6 +132,15 @@
text-transform: capitalize;
color: $color-text-muted;
font-weight: 500;
&[data-progress='ready'],
&[data-progress='picked_up'] {
color: $color-success;
}
&[data-progress='cancelled'] {
color: $color-danger;
}
}
.orderItems {
+8 -5
View File
@@ -2,7 +2,7 @@ import { Link } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts'
import { formatPrice, formatStatus } from '#/lib/format.ts'
import styles from './OrdersListView.module.scss'
@@ -96,11 +96,14 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => {
>
<div className={styles.orderHeader}>
<span className={styles.orderName}>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span>
<span
className={styles.orderStatus}
data-progress={order.progress}
>
{formatStatus(order.progress)}
</span>
</div>
<p className={styles.orderItems}>
{formatItems(order.items)}
</p>
<p className={styles.orderItems}>{formatItems(order.items)}</p>
<p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], {
month: 'short',
+7
View File
@@ -135,6 +135,9 @@ export const orderItemStatusValues = [
] as const
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
export const orderItemCancelledByValues = ['customer', 'kitchen'] as const
export type OrderItemCancelledBy = (typeof orderItemCancelledByValues)[number]
export const orderItems = pgTable('order_items', {
id: serial().primaryKey(),
orderId: integer('order_id')
@@ -146,6 +149,8 @@ export const orderItems = pgTable('order_items', {
// Captured at order time so later menu price changes don't rewrite history.
priceCents: integer('price_cents').notNull(),
status: text().$type<OrderItemStatus>().notNull().default('placed'),
// Only set once status is 'cancelled'.
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
@@ -195,6 +200,8 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
.notNull()
.references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
// Extra time a Machine reported on top of the estimate after claiming it.
delaySeconds: integer('delay_seconds').notNull().default(0),
status: text().$type<PreparationStepStatus>().notNull().default('queued'),
machineId: integer('machine_id').references(() => machines.id),
startedAt: timestamp('started_at'),
+194 -11
View File
@@ -549,14 +549,12 @@ export const cancelOrderItem = async (
.returning({ id: schema.preparationStepInstances.id })
if (deletedSteps.length === 0) {
throw new OrderItemNotCancellableError(
'it has already started cooking',
)
throw new OrderItemNotCancellableError('it has already started cooking')
}
await tx
.update(schema.orderItems)
.set({ status: 'cancelled' })
.set({ status: 'cancelled', cancelledBy: 'customer' })
.where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
@@ -626,6 +624,132 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
})
}
// A Machine reporting that the Step it's working on will take longer than
// estimated. Delays accumulate, and the extra time counts toward the ETA.
export const delayCurrentStep = async (
db: Db,
orderItemId: number,
delaySeconds: number,
) => {
const currentStep = await getCurrentStepInstance(db, orderItemId)
if (!currentStep || currentStep.status !== 'in_progress') {
throw new NoStepInProgressError()
}
await db
.update(schema.preparationStepInstances)
.set({ delaySeconds: currentStep.delaySeconds + delaySeconds })
.where(eq(schema.preparationStepInstances.id, currentStep.id))
}
// The kitchen giving up on an Order Item (e.g. a Machine fault), which unlike
// a customer cancellation is allowed at any point before it's ready — even
// mid-Step. The customer is refunded all the same.
export const cancelOrderItemByKitchen = async (
db: Db,
paymentProvider: PaymentProvider,
orderItemId: number,
) => {
const orderItem = firstRow(
await db
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
if (orderItem.status !== 'placed' && orderItem.status !== 'paid') {
throw new OrderItemNotCancellableError('it is no longer being prepared')
}
const order = firstRow(
await db
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
// Refund first, mirroring cancelOrderItem: a failed refund records nothing.
if (order.paymentChargeId) {
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
}
await db.transaction(async (tx) => {
const removedSteps = await tx
.delete(schema.preparationStepInstances)
.where(
and(
eq(schema.preparationStepInstances.orderItemId, orderItemId),
inArray(schema.preparationStepInstances.status, [
'queued',
'in_progress',
]),
),
)
.returning({ machineTagId: schema.preparationStepInstances.machineTagId })
await tx
.update(schema.orderItems)
.set({ status: 'cancelled', cancelledBy: 'kitchen' })
.where(eq(schema.orderItems.id, orderItemId))
for (const step of removedSteps) {
await assignQueuedStepsForTag(tx, step.machineTagId)
}
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
})
}
type SimulatedStep = Pick<
typeof schema.preparationStepInstances.$inferSelect,
'estimatedDurationSeconds' | 'delaySeconds'
>
export type KitchenSimulationPolicy = {
stepDurationMs: (step: SimulatedStep) => number
// What happens to a Step once its simulated duration is up.
pickOutcome: (
step: SimulatedStep,
) =>
| { type: 'finish' }
| { type: 'delay'; delaySeconds: number }
| { type: 'cancel' }
}
// Plays the part of every Machine at once for the dev kitchen simulation:
// settles each in-progress Step whose simulated duration has elapsed since
// its Machine claimed it, whichever Order it belongs to — otherwise a Step
// nobody is simulating would hold its Machine forever and starve the queue.
export const runKitchenSimulationTick = async (
db: Db,
paymentProvider: PaymentProvider,
policy: KitchenSimulationPolicy,
now = new Date(),
) => {
const inProgressSteps = await db
.select()
.from(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
const elapsedSteps = inProgressSteps.filter(
(step) =>
step.startedAt !== null &&
now.getTime() - step.startedAt.getTime() >= policy.stepDurationMs(step),
)
for (const step of elapsedSteps) {
const outcome = policy.pickOutcome(step)
if (outcome.type === 'delay') {
await delayCurrentStep(db, step.orderItemId, outcome.delaySeconds)
} else if (outcome.type === 'cancel') {
await cancelOrderItemByKitchen(db, paymentProvider, step.orderItemId)
} else {
await advanceOrderItemStep(db, step.orderItemId)
}
}
}
export const markOrderItemPickedUp = async (
db: Db,
orderItemId: number,
@@ -664,6 +788,9 @@ export type OrderItemStatusView = {
id: number
menuItemName: string
status: schema.OrderItemStatus
cancelledBy: schema.OrderItemCancelledBy | null
// Total delay reported across the item's Steps so far.
delaySeconds: number
cancellable: boolean
steps: Array<{
sequence: number
@@ -831,7 +958,9 @@ export const getOrderStatus = async (
: 0
if (etaSeconds !== null) {
etaSeconds += Math.max(
step.estimatedDurationSeconds - elapsedSeconds,
step.estimatedDurationSeconds +
instance.delaySeconds -
elapsedSeconds,
0,
)
}
@@ -886,6 +1015,8 @@ export const getOrderStatus = async (
id: item.id,
menuItemName: menuItem?.name ?? '',
status: item.status,
cancelledBy: item.cancelledBy,
delaySeconds: instances.reduce((sum, row) => sum + row.delaySeconds, 0),
cancellable:
item.status !== 'cancelled' &&
item.status !== 'ready' &&
@@ -907,9 +1038,38 @@ export const getOrderStatus = async (
}
}
// What a customer would say their Order is doing right now, derived from its
// Order Items. The Order's own status can't say this: it stays "paid" while
// items cook and only turns "complete" once every item is picked up or
// cancelled.
export const getOrderProgress = (
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
) => {
const activeItems = items.filter((item) => item.status !== 'cancelled')
const activeStatuses = activeItems.map((item) => item.status)
if (activeStatuses.length === 0) return 'cancelled'
if (activeStatuses.every((status) => status === 'picked_up'))
return 'picked_up'
if (
activeStatuses.every(
(status) => status === 'ready' || status === 'picked_up',
)
) {
return 'ready'
}
if (activeStatuses.includes('ready')) return 'partly_ready'
if (activeItems.some((item) => item.started || item.status === 'picked_up')) {
return 'preparing'
}
return 'not_started'
}
export type OrderProgress = ReturnType<typeof getOrderProgress>
export type CustomerOrderSummary = {
id: number
status: schema.OrderStatus
progress: OrderProgress
createdAt: Date
totalCents: number
items: Array<{ menuItemName: string; quantity: number }>
@@ -926,9 +1086,11 @@ const summarizeOrders = async (
const items = await db
.select({
id: schema.orderItems.id,
orderId: schema.orderItems.orderId,
menuItemName: schema.menuItems.name,
priceCents: schema.orderItems.priceCents,
status: schema.orderItems.status,
})
.from(schema.orderItems)
.innerJoin(
@@ -942,6 +1104,25 @@ const summarizeOrders = async (
),
)
const startedSteps = await db
.selectDistinct({
orderItemId: schema.preparationStepInstances.orderItemId,
})
.from(schema.preparationStepInstances)
.where(
and(
inArray(
schema.preparationStepInstances.orderItemId,
items.map((item) => item.id),
),
inArray(schema.preparationStepInstances.status, [
'in_progress',
'done',
]),
),
)
const startedItemIds = new Set(startedSteps.map((row) => row.orderItemId))
return orders.map((order) => {
const orderItems = items.filter((item) => item.orderId === order.id)
@@ -956,6 +1137,12 @@ const summarizeOrders = async (
return {
id: order.id,
status: order.status,
progress: getOrderProgress(
orderItems.map((item) => ({
status: item.status,
started: startedItemIds.has(item.id),
})),
),
createdAt: order.createdAt,
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
@@ -1052,9 +1239,7 @@ export const getGuestOrderSummaries = async (
const orders = await db
.select()
.from(schema.orders)
.where(
inArray(schema.orders.id, [...tokenByOrderId.keys()]),
)
.where(inArray(schema.orders.id, [...tokenByOrderId.keys()]))
const authorizedOrders = orders.filter(
(order) =>
order.guestAccessToken !== null &&
@@ -1062,7 +1247,5 @@ export const getGuestOrderSummaries = async (
)
const summaries = await summarizeOrders(db, authorizedOrders)
return summaries.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
)
return summaries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
+6 -1
View File
@@ -43,7 +43,10 @@ const startOfDay = (date: Date) =>
export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const target = new Date(now.getTime() + etaSeconds * 1000)
const time = target.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const time = target.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const dayDiff = Math.round(
(startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000,
)
@@ -59,3 +62,5 @@ export const formatEtaMessage = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}`
: `Ready in about ${formatDuration(etaSeconds)}`
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
+49 -4
View File
@@ -4,7 +4,11 @@ import { z } from 'zod'
import { db } from '#/db/index.ts'
import * as ordering from '#/domain/ordering.ts'
import { getPaymentProvider } from '#/lib/payment.ts'
import { getSessionCustomerId, getSessionUser, requireAdmin } from '#/server/auth.ts'
import {
getSessionCustomerId,
getSessionUser,
requireAdmin,
} from '#/server/auth.ts'
const customerSchema = z.discriminatedUnion('type', [
z.object({
@@ -92,9 +96,7 @@ export const getGuestOrders = createServerFn({ method: 'GET' })
),
}),
)
.handler(async ({ data }) =>
ordering.getGuestOrderSummaries(db, data.refs),
)
.handler(async ({ data }) => ordering.getGuestOrderSummaries(db, data.refs))
export const cancelOrderItem = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
@@ -122,6 +124,49 @@ export const markOrderItemPickedUp = createServerFn({ method: 'POST' })
// Deliberately not customer-facing and not access-checked here — in a real
// deployment this must be restricted to an internal/ops caller, not exposed
// on a route a customer's session could reach.
// Compresses a step's real-world estimated duration into something worth
// watching in a dev session, while keeping slower steps visibly slower than
// quick ones rather than flattening every step to the same delay.
const compressDurationMs = (seconds: number) =>
Math.min(Math.max(seconds * 50, 10_000), 30_000)
const SIMULATED_CANCEL_CHANCE = 0.015
const SIMULATED_DELAY_CHANCE = 0.08
const kitchenSimulationPolicy: ordering.KitchenSimulationPolicy = {
stepDurationMs: (step) =>
compressDurationMs(step.estimatedDurationSeconds) +
(step.delaySeconds > 0 ? compressDurationMs(step.delaySeconds) : 0),
pickOutcome: (step) => {
const roll = Math.random()
if (roll < SIMULATED_CANCEL_CHANCE) return { type: 'cancel' }
// One delay per Step at most, so a delayed Step always does finish.
if (
step.delaySeconds === 0 &&
roll < SIMULATED_CANCEL_CHANCE + SIMULATED_DELAY_CHANCE
) {
return {
type: 'delay',
delaySeconds: 120 + Math.floor(Math.random() * 180),
}
}
return { type: 'finish' }
},
}
export const advanceSimulatedKitchen = createServerFn({
method: 'POST',
}).handler(async () => {
if (!import.meta.env.DEV) {
throw new Error('The kitchen simulation is only available in development')
}
await ordering.runKitchenSimulationTick(
db,
getPaymentProvider(),
kitchenSimulationPolicy,
)
})
export const advanceOrderItemStep = createServerFn({ method: 'POST' })
.validator(z.object({ orderItemId: z.number() }))
.handler(async ({ data }) =>
+1
View File
@@ -11,6 +11,7 @@ $color-primary: var(--color-primary);
$color-primary-contrast: var(--color-primary-contrast);
$color-danger: var(--color-danger);
$color-success: var(--color-success);
$color-warning: var(--color-warning);
$space-1: 0.25rem;
$space-2: 0.5rem;
+2
View File
@@ -10,6 +10,7 @@
--color-primary-contrast: #ffffff;
--color-danger: #a33333;
--color-success: #2f7a4f;
--color-warning: #9a6200;
color-scheme: light;
}
@@ -24,6 +25,7 @@
--color-primary-contrast: #ffffff;
--color-danger: #ef6b62;
--color-success: #4fbb7c;
--color-warning: #e0a83e;
color-scheme: dark;
}
}