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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user