Add a dev-only kitchen simulation to the order status page

- "Simulate kitchen (dev)" button advances each placed/paid item's
  Preparation Steps as a Machine would, polling fresh status per item
- Each simulated step takes 10–30s, scaled from its estimated duration
- Timeline connector fills in with a Framer Motion animation when a
  step completes; finished steps and the line now use the success green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:25:52 +02:00
co-authored by Claude Opus 5.5
parent 815939cbc6
commit c124b0415a
2 changed files with 130 additions and 10 deletions
@@ -55,6 +55,28 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
.simulateButton {
margin: 0 0 $space-4;
padding: $space-1 $space-3;
border: 1px dashed $color-text-muted;
border-radius: $radius;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
&:hover:not(:disabled) {
border-color: $color-primary;
color: $color-primary;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.error { .error {
color: $color-danger; color: $color-danger;
} }
@@ -130,8 +152,7 @@
z-index: 1; z-index: 1;
} }
.stepMarker::after { .stepConnector {
content: '';
position: absolute; position: absolute;
top: 15px; top: 15px;
bottom: -$space-3; bottom: -$space-3;
@@ -139,9 +160,17 @@
width: 2px; width: 2px;
background: $color-border; background: $color-border;
transform: translateX(-50%); transform: translateX(-50%);
overflow: hidden;
} }
.step:last-child .stepMarker::after { .stepConnectorFill {
position: absolute;
inset: 0;
background: $color-success;
transform-origin: top;
}
.step:last-child .stepConnector {
display: none; display: none;
} }
@@ -158,12 +187,8 @@
.step[data-status='done'] { .step[data-status='done'] {
.stepDot { .stepDot {
border-color: $color-primary; border-color: $color-success;
background: $color-primary; background: $color-success;
}
.stepMarker::after {
background: $color-primary;
} }
.stepLabel { .stepLabel {
+96 -1
View File
@@ -1,13 +1,27 @@
import { Link, useRouter } from '@tanstack/react-router' import { Link, useRouter } from '@tanstack/react-router'
import { motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react' import { useState } from 'react'
import type { OrderStatus } from '#/db/schema.ts' import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts' import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { formatEtaMessage } from '#/lib/format.ts' import { formatEtaMessage } from '#/lib/format.ts'
import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts' import {
advanceOrderItemStep,
cancelOrderItem,
getOrderStatus,
markOrderItemPickedUp,
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss' import styles from './OrderStatusView.module.scss'
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 cancelBlockedReason = (item: OrderItemStatusView) => { 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 return null
@@ -51,6 +65,20 @@ const InfoIcon = () => (
</svg> </svg>
) )
const StepConnector = ({ filled }: { filled: boolean }) => {
const reduceMotion = useReducedMotion()
return (
<span className={styles.stepConnector}>
<motion.span
className={styles.stepConnectorFill}
initial={false}
animate={{ scaleY: filled ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }}
/>
</span>
)
}
export type OrderStatusResult = { export type OrderStatusResult = {
id: number id: number
status: OrderStatus status: OrderStatus
@@ -72,6 +100,7 @@ export const OrderStatusView = ({
const router = useRouter() const router = useRouter()
const [pendingItemId, setPendingItemId] = useState<number | null>(null) const [pendingItemId, setPendingItemId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [simulating, setSimulating] = useState(false)
const runAction = async (orderItemId: number, action: () => Promise<void>) => { const runAction = async (orderItemId: number, action: () => Promise<void>) => {
setPendingItemId(orderItemId) setPendingItemId(orderItemId)
@@ -86,6 +115,61 @@ 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)))
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setSimulating(false)
}
}
const hasItemsToSimulate = status.items.some(
(item) => item.status === 'placed' || item.status === 'paid',
)
return ( return (
<section className={styles.page}> <section className={styles.page}>
<Link <Link
@@ -112,6 +196,16 @@ export const OrderStatusView = ({
})} })}
</p> </p>
</div> </div>
{import.meta.env.DEV && hasItemsToSimulate && (
<button
type="button"
className={styles.simulateButton}
disabled={simulating}
onClick={() => void runKitchenSimulation()}
>
{simulating ? 'Simulating kitchen…' : 'Simulate kitchen (dev)'}
</button>
)}
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}> <ul className={styles.items}>
{status.items.map((item) => { {status.items.map((item) => {
@@ -137,6 +231,7 @@ export const OrderStatusView = ({
> >
<span className={styles.stepMarker} aria-hidden="true"> <span className={styles.stepMarker} aria-hidden="true">
<span className={styles.stepDot} /> <span className={styles.stepDot} />
<StepConnector filled={step.status === 'done'} />
</span> </span>
<span className={styles.stepContent}> <span className={styles.stepContent}>
<span className={styles.stepLabel}>{step.name}</span> <span className={styles.stepLabel}>{step.name}</span>