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:
@@ -55,6 +55,28 @@
|
||||
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 {
|
||||
color: $color-danger;
|
||||
}
|
||||
@@ -130,8 +152,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stepMarker::after {
|
||||
content: '';
|
||||
.stepConnector {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
bottom: -$space-3;
|
||||
@@ -139,9 +160,17 @@
|
||||
width: 2px;
|
||||
background: $color-border;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -158,12 +187,8 @@
|
||||
|
||||
.step[data-status='done'] {
|
||||
.stepDot {
|
||||
border-color: $color-primary;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.stepMarker::after {
|
||||
background: $color-primary;
|
||||
border-color: $color-success;
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
import { 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 { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
|
||||
import {
|
||||
advanceOrderItemStep,
|
||||
cancelOrderItem,
|
||||
getOrderStatus,
|
||||
markOrderItemPickedUp,
|
||||
} from '#/server/orders.ts'
|
||||
|
||||
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) => {
|
||||
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
|
||||
return null
|
||||
@@ -51,6 +65,20 @@ const InfoIcon = () => (
|
||||
</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 = {
|
||||
id: number
|
||||
status: OrderStatus
|
||||
@@ -72,6 +100,7 @@ export const OrderStatusView = ({
|
||||
const router = useRouter()
|
||||
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [simulating, setSimulating] = useState(false)
|
||||
|
||||
const runAction = async (orderItemId: number, action: () => Promise<void>) => {
|
||||
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 (
|
||||
<section className={styles.page}>
|
||||
<Link
|
||||
@@ -112,6 +196,16 @@ export const OrderStatusView = ({
|
||||
})}
|
||||
</p>
|
||||
</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>}
|
||||
<ul className={styles.items}>
|
||||
{status.items.map((item) => {
|
||||
@@ -137,6 +231,7 @@ export const OrderStatusView = ({
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user