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 = ({ >