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;
|
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user