diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss
index 21c160d..382feae 100644
--- a/src/components/orders/OrderStatusView.module.scss
+++ b/src/components/orders/OrderStatusView.module.scss
@@ -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 {
diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx
index 2addf9d..806d2eb 100644
--- a/src/components/orders/OrderStatusView.tsx
+++ b/src/components/orders/OrderStatusView.tsx
@@ -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 = () => (
)
+const StepConnector = ({ filled }: { filled: boolean }) => {
+ const reduceMotion = useReducedMotion()
+ return (
+
+
+
+ )
+}
+
export type OrderStatusResult = {
id: number
status: OrderStatus
@@ -72,6 +100,7 @@ export const OrderStatusView = ({
const router = useRouter()
const [pendingItemId, setPendingItemId] = useState(null)
const [error, setError] = useState(null)
+ const [simulating, setSimulating] = useState(false)
const runAction = async (orderItemId: number, action: () => Promise) => {
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 (
+ {import.meta.env.DEV && hasItemsToSimulate && (
+
+ )}
{error && {error}
}
{status.items.map((item) => {
@@ -137,6 +231,7 @@ export const OrderStatusView = ({
>
+
{step.name}