Show live order progress, delays, and kitchen cancellations

- Order cards derive their label from item progress (Not Started,
  Preparing, Partly Ready, Ready, Picked Up, Cancelled) instead of the
  order's own status; final states are coloured
- Order page: animated status banner per item (ready/picked up check,
  cancelled cross, running-late clock), steps collapse once an item is
  settled with a Show/Hide steps toggle, done steps are faded instead
  of struck through, and the per-item status badge is gone
- New Delay concept: machines can report extra time on a claimed step
  (preparation_step_instances.delay_seconds), which counts toward the ETA
- The kitchen can cancel an item at any point before it's ready, with a
  refund (order_items.cancelled_by records who cancelled)
- Dev kitchen simulation now runs server-side across every order, so a
  step nobody is watching no longer blocks the queue; each settled step
  may randomly be delayed or cancelled
- Humanize snake_case statuses in the UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:11:08 +02:00
co-authored by Claude Opus 5.5
parent c124b0415a
commit a6ac32d783
17 changed files with 1651 additions and 111 deletions
+4 -1
View File
@@ -23,7 +23,7 @@ The single payment transaction covering an Order's full total, created once at p
_Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments) _Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments)
**Order Item**: **Order Item**:
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress). Separately, the Kitchen itself can cancel an Order Item at any point before it's ready (e.g. a Machine fault), even mid-Step, also with a refund.
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state) _Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
**Guest Access Token**: **Guest Access Token**:
@@ -37,6 +37,9 @@ The ordered list of Preparation Steps required to prepare a Menu Item. A Menu It
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion. One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
_Avoid_: Stage (use Step consistently) _Avoid_: Stage (use Step consistently)
**Delay**:
Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion and are shown to the customer as the item running late.
**Machine**: **Machine**:
A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel. A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel.
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "order_items" ADD COLUMN "cancelled_by" text;--> statement-breakpoint
ALTER TABLE "preparation_step_instances" ADD COLUMN "delay_seconds" integer DEFAULT 0 NOT NULL;
+883
View File
@@ -0,0 +1,883 @@
{
"id": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
"prevId": "4086d6cd-5c93-4617-a15b-a90331b459a1",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allergens": {
"name": "allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"allergens_name_idx": {
"name": "allergens_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_allergens": {
"name": "menu_item_allergens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"allergen_id": {
"name": "allergen_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"menu_item_allergens_item_allergen_idx": {
"name": "menu_item_allergens_item_allergen_idx",
"columns": [
{
"expression": "menu_item_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "allergen_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_allergens_allergen_id_allergens_id_fk": {
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
"tableFrom": "menu_item_allergens",
"tableTo": "allergens",
"columnsFrom": [
"allergen_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_options": {
"name": "menu_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"menu_item_options_menu_item_id_menu_items_id_fk": {
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_options",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_options": {
"name": "order_item_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price_delta_cents": {
"name": "price_delta_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_options_order_item_id_order_items_id_fk": {
"name": "order_item_options_order_item_id_order_items_id_fk",
"tableFrom": "order_item_options",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"cancelled_by": {
"name": "cancelled_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delay_seconds": {
"name": "delay_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -50,6 +50,13 @@
"when": 1789637974330, "when": 1789637974330,
"tag": "0006_worried_shooting_star", "tag": "0006_worried_shooting_star",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1790804884144,
"tag": "0007_dazzling_norman_osborn",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,42 @@
@use '../../styles/variables' as *;
.banner {
display: flex;
align-items: center;
gap: $space-2;
color: $color-success;
&[data-variant='cancelled'] {
color: $color-danger;
}
&[data-variant='delayed'] {
color: $color-warning;
}
}
.icon {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.text {
display: flex;
flex-direction: column;
}
.label {
font-size: 0.9rem;
font-weight: 600;
}
.detail {
font-size: 0.8rem;
color: $color-text-muted;
}
@@ -0,0 +1,69 @@
import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant =
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
delayed: ['M12 7v5', 'M12 12l3.5 2'],
} satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = {
variant: ItemStatusBannerVariant
label: string
detail?: string
animateIn: boolean
}
export const ItemStatusBanner = ({
variant,
label,
detail,
animateIn,
}: ItemStatusBannerProps) => {
const reduceMotion = useReducedMotion()
const skipAnimation = !animateIn || reduceMotion
return (
<motion.div
className={styles.banner}
data-variant={variant}
role="status"
initial={skipAnimation ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<motion.circle
cx="12"
cy="12"
r="10"
initial={skipAnimation ? false : { pathLength: 0, rotate: -90 }}
animate={{ pathLength: 1, rotate: -90 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
{iconPaths[variant].map((d, index) => (
<motion.path
key={d}
d={d}
initial={skipAnimation ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.3,
ease: 'easeOut',
delay: 0.45 + index * 0.2,
}}
/>
))}
</svg>
<span className={styles.text}>
<span className={styles.label}>{label}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</span>
</motion.div>
)
}
@@ -64,7 +64,9 @@
color: $color-text-muted; color: $color-text-muted;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease; transition:
border-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) { &:hover:not(:disabled) {
border-color: $color-primary; border-color: $color-primary;
@@ -104,17 +106,42 @@
margin-bottom: $space-2; margin-bottom: $space-2;
} }
.itemStatus {
text-transform: capitalize;
color: $color-text-muted;
}
.itemOptions { .itemOptions {
margin: 0 0 $space-2; margin: 0 0 $space-2;
font-size: 0.85rem; font-size: 0.85rem;
color: $color-text-muted; color: $color-text-muted;
} }
// The negative margin + matching padding leaves room for the in-progress
// dot's sonar ping, which the collapse animation's overflow would clip.
.timelineWrapper {
overflow: hidden;
margin: (-$space-2);
padding: $space-2;
}
.bannerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-2;
margin: 0 0 $space-2;
}
.stepsToggle {
padding: 0;
border: none;
background: none;
color: $color-text-muted;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $color-primary;
}
}
.timeline { .timeline {
margin: 0 0 $space-2; margin: 0 0 $space-2;
padding: 0; padding: 0;
@@ -186,6 +213,8 @@
} }
.step[data-status='done'] { .step[data-status='done'] {
opacity: 0.5;
.stepDot { .stepDot {
border-color: $color-success; border-color: $color-success;
background: $color-success; background: $color-success;
@@ -193,7 +222,6 @@
.stepLabel { .stepLabel {
color: $color-text; color: $color-text;
text-decoration: line-through;
} }
} }
+165 -58
View File
@@ -1,29 +1,82 @@
import { Link, useRouter } from '@tanstack/react-router' import { Link, useRouter } from '@tanstack/react-router'
import { motion, useReducedMotion } from 'framer-motion' import { AnimatePresence, 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 { formatDuration, formatEtaMessage } from '#/lib/format.ts'
import { import {
advanceOrderItemStep, advanceSimulatedKitchen,
cancelOrderItem, cancelOrderItem,
getOrderStatus, getOrderStatus,
markOrderItemPickedUp, markOrderItemPickedUp,
} from '#/server/orders.ts' } from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss' import styles from './OrderStatusView.module.scss'
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// Compresses a step's real-world estimated duration into something worth const isSettled = (item: OrderItemStatusView) =>
// watching in a dev session, while keeping slower steps visibly slower than item.status === 'ready' ||
// quick ones rather than flattening every step to the same delay. item.status === 'picked_up' ||
const simulatedStepDelayMs = (estimatedDurationSeconds: number) => item.status === 'cancelled'
Math.min(Math.max(estimatedDurationSeconds * 50, 10_000), 30_000)
// `animationKey` groups variants that should read as one continuous banner:
// going from ready to picked up swaps the label without replaying the check.
const getItemBanner = (item: OrderItemStatusView) => {
if (item.status === 'ready') {
return {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
} as const
}
if (item.status === 'picked_up') {
return {
animationKey: 'done',
variant: 'pickedUp',
label: 'Picked up',
} as const
}
if (item.status === 'cancelled') {
return item.cancelledBy === 'kitchen'
? ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled by the kitchen',
detail:
"Something went wrong on our end, so we've refunded this item.",
} as const)
: ({
animationKey: 'cancelled',
variant: 'cancelled',
label: 'Cancelled',
detail: "You've been refunded for this item.",
} as const)
}
if (item.delaySeconds > 0) {
return {
animationKey: 'delayed',
variant: 'delayed',
label: 'Running late',
detail: `About ${formatDuration(item.delaySeconds)} behind schedule`,
} as const
}
return null
}
const bannerKey = (item: OrderItemStatusView) => {
const banner = getItemBanner(item)
return banner ? `${item.id}:${banner.animationKey}` : null
}
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
} }
if (item.status === 'ready') { if (item.status === 'ready') {
@@ -73,7 +126,9 @@ const StepConnector = ({ filled }: { filled: boolean }) => {
className={styles.stepConnectorFill} className={styles.stepConnectorFill}
initial={false} initial={false}
animate={{ scaleY: filled ? 1 : 0 }} animate={{ scaleY: filled ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }} transition={
reduceMotion ? { duration: 0 } : { duration: 0.6, ease: 'easeInOut' }
}
/> />
</span> </span>
) )
@@ -101,8 +156,31 @@ export const OrderStatusView = ({
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 [simulating, setSimulating] = useState(false)
// Only banners that appear while the page is open animate in; ones
// already showing on load render straight away.
const [initialBannerKeys] = useState(
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
)
const [expandedItemIds, setExpandedItemIds] = useState<ReadonlySet<number>>(
new Set(),
)
const reduceMotion = useReducedMotion()
const collapseTransition = reduceMotion
? { duration: 0 }
: { duration: 0.4, ease: 'easeInOut' as const }
const runAction = async (orderItemId: number, action: () => Promise<void>) => { const toggleSteps = (orderItemId: number) => {
setExpandedItemIds((current) => {
const next = new Set(current)
if (!next.delete(orderItemId)) next.add(orderItemId)
return next
})
}
const runAction = async (
orderItemId: number,
action: () => Promise<void>,
) => {
setPendingItemId(orderItemId) setPendingItemId(orderItemId)
setError(null) setError(null)
try { try {
@@ -115,50 +193,25 @@ 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 () => { const runKitchenSimulation = async () => {
setSimulating(true) setSimulating(true)
setError(null) setError(null)
try { try {
const itemIds = status.items for (;;) {
.filter((item) => item.status === 'placed' || item.status === 'paid') await advanceSimulatedKitchen()
.map((item) => item.id) const fresh = await getOrderStatus({
await Promise.all(itemIds.map((orderItemId) => simulateOrderItem(orderItemId))) data: { orderId: status.id, guestAccessToken },
})
await router.invalidate()
if (
!fresh.items.some(
(item) => item.status === 'placed' || item.status === 'paid',
)
) {
return
}
await sleep(1000)
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.') setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally { } finally {
@@ -210,32 +263,51 @@ export const OrderStatusView = ({
<ul className={styles.items}> <ul className={styles.items}>
{status.items.map((item) => { {status.items.map((item) => {
const cancelHint = cancelBlockedReason(item) const cancelHint = cancelBlockedReason(item)
const banner = getItemBanner(item)
return ( return (
<li key={item.id} className={styles.item}> <li key={item.id} className={styles.item}>
<div className={styles.itemHeader}> <div className={styles.itemHeader}>
<span className={styles.itemName}>{item.menuItemName}</span> <span className={styles.itemName}>{item.menuItemName}</span>
<span className={styles.itemStatus}>{item.status}</span>
</div> </div>
{item.options.length > 0 && ( {item.options.length > 0 && (
<p className={styles.itemOptions}> <p className={styles.itemOptions}>
{item.options.map((option) => option.name).join(', ')} {item.options.map((option) => option.name).join(', ')}
</p> </p>
)} )}
<AnimatePresence initial={false}>
{(!isSettled(item) || expandedItemIds.has(item.id)) && (
<motion.div
key="timeline"
id={`steps-${item.id}`}
className={styles.timelineWrapper}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={collapseTransition}
>
<ol className={styles.timeline}> <ol className={styles.timeline}>
{item.steps.map((step) => ( {item.steps.map((step) => (
<li <li
key={step.sequence} key={step.sequence}
className={styles.step} className={styles.step}
data-status={step.status} data-status={step.status}
aria-current={step.status === 'in_progress' ? 'step' : undefined} aria-current={
step.status === 'in_progress' ? 'step' : undefined
}
>
<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'} /> <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.status === 'queued' && step.queuePosition !== null && ( {step.name}
</span>
{step.status === 'queued' &&
step.queuePosition !== null && (
<span className={styles.stepMeta}> <span className={styles.stepMeta}>
#{step.queuePosition} in line #{step.queuePosition} in line
</span> </span>
@@ -244,8 +316,41 @@ export const OrderStatusView = ({
</li> </li>
))} ))}
</ol> </ol>
</motion.div>
)}
</AnimatePresence>
{banner && (
<div className={styles.bannerRow}>
<ItemStatusBanner
key={banner.animationKey}
variant={banner.variant}
label={banner.label}
detail={'detail' in banner ? banner.detail : undefined}
animateIn={
!initialBannerKeys.has(
`${item.id}:${banner.animationKey}`,
)
}
/>
{isSettled(item) && (
<button
type="button"
className={styles.stepsToggle}
aria-expanded={expandedItemIds.has(item.id)}
aria-controls={`steps-${item.id}`}
onClick={() => toggleSteps(item.id)}
>
{expandedItemIds.has(item.id)
? 'Hide steps'
: 'Show steps'}
</button>
)}
</div>
)}
{item.etaSeconds !== null && ( {item.etaSeconds !== null && (
<p className={styles.eta}>{formatEtaMessage(item.etaSeconds)}</p> <p className={styles.eta}>
{formatEtaMessage(item.etaSeconds)}
</p>
)} )}
<div className={styles.actions}> <div className={styles.actions}>
<button <button
@@ -299,7 +404,9 @@ export const OrderStatusView = ({
popover="auto" popover="auto"
role="tooltip" role="tooltip"
className={styles.cancelHint} className={styles.cancelHint}
style={{ positionAnchor: `--cancel-hint-anchor-${item.id}` }} style={{
positionAnchor: `--cancel-hint-anchor-${item.id}`,
}}
> >
{cancelHint} {cancelHint}
</p> </p>
@@ -132,6 +132,15 @@
text-transform: capitalize; text-transform: capitalize;
color: $color-text-muted; color: $color-text-muted;
font-weight: 500; font-weight: 500;
&[data-progress='ready'],
&[data-progress='picked_up'] {
color: $color-success;
}
&[data-progress='cancelled'] {
color: $color-danger;
}
} }
.orderItems { .orderItems {
+8 -5
View File
@@ -2,7 +2,7 @@ import { Link } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { CustomerOrderSummary } from '#/domain/ordering.ts' import type { CustomerOrderSummary } from '#/domain/ordering.ts'
import { formatPrice } from '#/lib/format.ts' import { formatPrice, formatStatus } from '#/lib/format.ts'
import styles from './OrdersListView.module.scss' import styles from './OrdersListView.module.scss'
@@ -96,11 +96,14 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => {
> >
<div className={styles.orderHeader}> <div className={styles.orderHeader}>
<span className={styles.orderName}>Order #{order.id}</span> <span className={styles.orderName}>Order #{order.id}</span>
<span className={styles.orderStatus}>{order.status}</span> <span
className={styles.orderStatus}
data-progress={order.progress}
>
{formatStatus(order.progress)}
</span>
</div> </div>
<p className={styles.orderItems}> <p className={styles.orderItems}>{formatItems(order.items)}</p>
{formatItems(order.items)}
</p>
<p className={styles.orderMeta}> <p className={styles.orderMeta}>
{order.createdAt.toLocaleDateString([], { {order.createdAt.toLocaleDateString([], {
month: 'short', month: 'short',
+7
View File
@@ -135,6 +135,9 @@ export const orderItemStatusValues = [
] as const ] as const
export type OrderItemStatus = (typeof orderItemStatusValues)[number] export type OrderItemStatus = (typeof orderItemStatusValues)[number]
export const orderItemCancelledByValues = ['customer', 'kitchen'] as const
export type OrderItemCancelledBy = (typeof orderItemCancelledByValues)[number]
export const orderItems = pgTable('order_items', { export const orderItems = pgTable('order_items', {
id: serial().primaryKey(), id: serial().primaryKey(),
orderId: integer('order_id') orderId: integer('order_id')
@@ -146,6 +149,8 @@ export const orderItems = pgTable('order_items', {
// Captured at order time so later menu price changes don't rewrite history. // Captured at order time so later menu price changes don't rewrite history.
priceCents: integer('price_cents').notNull(), priceCents: integer('price_cents').notNull(),
status: text().$type<OrderItemStatus>().notNull().default('placed'), status: text().$type<OrderItemStatus>().notNull().default('placed'),
// Only set once status is 'cancelled'.
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}) })
@@ -195,6 +200,8 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
.notNull() .notNull()
.references(() => machineTags.id), .references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(), estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
// Extra time a Machine reported on top of the estimate after claiming it.
delaySeconds: integer('delay_seconds').notNull().default(0),
status: text().$type<PreparationStepStatus>().notNull().default('queued'), status: text().$type<PreparationStepStatus>().notNull().default('queued'),
machineId: integer('machine_id').references(() => machines.id), machineId: integer('machine_id').references(() => machines.id),
startedAt: timestamp('started_at'), startedAt: timestamp('started_at'),
+194 -11
View File
@@ -549,14 +549,12 @@ export const cancelOrderItem = async (
.returning({ id: schema.preparationStepInstances.id }) .returning({ id: schema.preparationStepInstances.id })
if (deletedSteps.length === 0) { if (deletedSteps.length === 0) {
throw new OrderItemNotCancellableError( throw new OrderItemNotCancellableError('it has already started cooking')
'it has already started cooking',
)
} }
await tx await tx
.update(schema.orderItems) .update(schema.orderItems)
.set({ status: 'cancelled' }) .set({ status: 'cancelled', cancelledBy: 'customer' })
.where(eq(schema.orderItems.id, orderItemId)) .where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
@@ -626,6 +624,132 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
}) })
} }
// A Machine reporting that the Step it's working on will take longer than
// estimated. Delays accumulate, and the extra time counts toward the ETA.
export const delayCurrentStep = async (
db: Db,
orderItemId: number,
delaySeconds: number,
) => {
const currentStep = await getCurrentStepInstance(db, orderItemId)
if (!currentStep || currentStep.status !== 'in_progress') {
throw new NoStepInProgressError()
}
await db
.update(schema.preparationStepInstances)
.set({ delaySeconds: currentStep.delaySeconds + delaySeconds })
.where(eq(schema.preparationStepInstances.id, currentStep.id))
}
// The kitchen giving up on an Order Item (e.g. a Machine fault), which unlike
// a customer cancellation is allowed at any point before it's ready — even
// mid-Step. The customer is refunded all the same.
export const cancelOrderItemByKitchen = async (
db: Db,
paymentProvider: PaymentProvider,
orderItemId: number,
) => {
const orderItem = firstRow(
await db
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
if (orderItem.status !== 'placed' && orderItem.status !== 'paid') {
throw new OrderItemNotCancellableError('it is no longer being prepared')
}
const order = firstRow(
await db
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
// Refund first, mirroring cancelOrderItem: a failed refund records nothing.
if (order.paymentChargeId) {
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
}
await db.transaction(async (tx) => {
const removedSteps = await tx
.delete(schema.preparationStepInstances)
.where(
and(
eq(schema.preparationStepInstances.orderItemId, orderItemId),
inArray(schema.preparationStepInstances.status, [
'queued',
'in_progress',
]),
),
)
.returning({ machineTagId: schema.preparationStepInstances.machineTagId })
await tx
.update(schema.orderItems)
.set({ status: 'cancelled', cancelledBy: 'kitchen' })
.where(eq(schema.orderItems.id, orderItemId))
for (const step of removedSteps) {
await assignQueuedStepsForTag(tx, step.machineTagId)
}
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
})
}
type SimulatedStep = Pick<
typeof schema.preparationStepInstances.$inferSelect,
'estimatedDurationSeconds' | 'delaySeconds'
>
export type KitchenSimulationPolicy = {
stepDurationMs: (step: SimulatedStep) => number
// What happens to a Step once its simulated duration is up.
pickOutcome: (
step: SimulatedStep,
) =>
| { type: 'finish' }
| { type: 'delay'; delaySeconds: number }
| { type: 'cancel' }
}
// Plays the part of every Machine at once for the dev kitchen simulation:
// settles each in-progress Step whose simulated duration has elapsed since
// its Machine claimed it, whichever Order it belongs to — otherwise a Step
// nobody is simulating would hold its Machine forever and starve the queue.
export const runKitchenSimulationTick = async (
db: Db,
paymentProvider: PaymentProvider,
policy: KitchenSimulationPolicy,
now = new Date(),
) => {
const inProgressSteps = await db
.select()
.from(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
const elapsedSteps = inProgressSteps.filter(
(step) =>
step.startedAt !== null &&
now.getTime() - step.startedAt.getTime() >= policy.stepDurationMs(step),
)
for (const step of elapsedSteps) {
const outcome = policy.pickOutcome(step)
if (outcome.type === 'delay') {
await delayCurrentStep(db, step.orderItemId, outcome.delaySeconds)
} else if (outcome.type === 'cancel') {
await cancelOrderItemByKitchen(db, paymentProvider, step.orderItemId)
} else {
await advanceOrderItemStep(db, step.orderItemId)
}
}
}
export const markOrderItemPickedUp = async ( export const markOrderItemPickedUp = async (
db: Db, db: Db,
orderItemId: number, orderItemId: number,
@@ -664,6 +788,9 @@ export type OrderItemStatusView = {
id: number id: number
menuItemName: string menuItemName: string
status: schema.OrderItemStatus status: schema.OrderItemStatus
cancelledBy: schema.OrderItemCancelledBy | null
// Total delay reported across the item's Steps so far.
delaySeconds: number
cancellable: boolean cancellable: boolean
steps: Array<{ steps: Array<{
sequence: number sequence: number
@@ -831,7 +958,9 @@ export const getOrderStatus = async (
: 0 : 0
if (etaSeconds !== null) { if (etaSeconds !== null) {
etaSeconds += Math.max( etaSeconds += Math.max(
step.estimatedDurationSeconds - elapsedSeconds, step.estimatedDurationSeconds +
instance.delaySeconds -
elapsedSeconds,
0, 0,
) )
} }
@@ -886,6 +1015,8 @@ export const getOrderStatus = async (
id: item.id, id: item.id,
menuItemName: menuItem?.name ?? '', menuItemName: menuItem?.name ?? '',
status: item.status, status: item.status,
cancelledBy: item.cancelledBy,
delaySeconds: instances.reduce((sum, row) => sum + row.delaySeconds, 0),
cancellable: cancellable:
item.status !== 'cancelled' && item.status !== 'cancelled' &&
item.status !== 'ready' && item.status !== 'ready' &&
@@ -907,9 +1038,38 @@ export const getOrderStatus = async (
} }
} }
// What a customer would say their Order is doing right now, derived from its
// Order Items. The Order's own status can't say this: it stays "paid" while
// items cook and only turns "complete" once every item is picked up or
// cancelled.
export const getOrderProgress = (
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
) => {
const activeItems = items.filter((item) => item.status !== 'cancelled')
const activeStatuses = activeItems.map((item) => item.status)
if (activeStatuses.length === 0) return 'cancelled'
if (activeStatuses.every((status) => status === 'picked_up'))
return 'picked_up'
if (
activeStatuses.every(
(status) => status === 'ready' || status === 'picked_up',
)
) {
return 'ready'
}
if (activeStatuses.includes('ready')) return 'partly_ready'
if (activeItems.some((item) => item.started || item.status === 'picked_up')) {
return 'preparing'
}
return 'not_started'
}
export type OrderProgress = ReturnType<typeof getOrderProgress>
export type CustomerOrderSummary = { export type CustomerOrderSummary = {
id: number id: number
status: schema.OrderStatus status: schema.OrderStatus
progress: OrderProgress
createdAt: Date createdAt: Date
totalCents: number totalCents: number
items: Array<{ menuItemName: string; quantity: number }> items: Array<{ menuItemName: string; quantity: number }>
@@ -926,9 +1086,11 @@ const summarizeOrders = async (
const items = await db const items = await db
.select({ .select({
id: schema.orderItems.id,
orderId: schema.orderItems.orderId, orderId: schema.orderItems.orderId,
menuItemName: schema.menuItems.name, menuItemName: schema.menuItems.name,
priceCents: schema.orderItems.priceCents, priceCents: schema.orderItems.priceCents,
status: schema.orderItems.status,
}) })
.from(schema.orderItems) .from(schema.orderItems)
.innerJoin( .innerJoin(
@@ -942,6 +1104,25 @@ const summarizeOrders = async (
), ),
) )
const startedSteps = await db
.selectDistinct({
orderItemId: schema.preparationStepInstances.orderItemId,
})
.from(schema.preparationStepInstances)
.where(
and(
inArray(
schema.preparationStepInstances.orderItemId,
items.map((item) => item.id),
),
inArray(schema.preparationStepInstances.status, [
'in_progress',
'done',
]),
),
)
const startedItemIds = new Set(startedSteps.map((row) => row.orderItemId))
return orders.map((order) => { return orders.map((order) => {
const orderItems = items.filter((item) => item.orderId === order.id) const orderItems = items.filter((item) => item.orderId === order.id)
@@ -956,6 +1137,12 @@ const summarizeOrders = async (
return { return {
id: order.id, id: order.id,
status: order.status, status: order.status,
progress: getOrderProgress(
orderItems.map((item) => ({
status: item.status,
started: startedItemIds.has(item.id),
})),
),
createdAt: order.createdAt, createdAt: order.createdAt,
totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0), totalCents: orderItems.reduce((sum, item) => sum + item.priceCents, 0),
items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({ items: [...quantityByMenuItemName].map(([menuItemName, quantity]) => ({
@@ -1052,9 +1239,7 @@ export const getGuestOrderSummaries = async (
const orders = await db const orders = await db
.select() .select()
.from(schema.orders) .from(schema.orders)
.where( .where(inArray(schema.orders.id, [...tokenByOrderId.keys()]))
inArray(schema.orders.id, [...tokenByOrderId.keys()]),
)
const authorizedOrders = orders.filter( const authorizedOrders = orders.filter(
(order) => (order) =>
order.guestAccessToken !== null && order.guestAccessToken !== null &&
@@ -1062,7 +1247,5 @@ export const getGuestOrderSummaries = async (
) )
const summaries = await summarizeOrders(db, authorizedOrders) const summaries = await summarizeOrders(db, authorizedOrders)
return summaries.sort( return summaries.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
)
} }
+6 -1
View File
@@ -43,7 +43,10 @@ const startOfDay = (date: Date) =>
export const formatEtaClock = (etaSeconds: number, now = new Date()) => { export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const target = new Date(now.getTime() + etaSeconds * 1000) const target = new Date(now.getTime() + etaSeconds * 1000)
const time = target.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const time = target.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const dayDiff = Math.round( const dayDiff = Math.round(
(startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000, (startOfDay(target).getTime() - startOfDay(now).getTime()) / 86_400_000,
) )
@@ -59,3 +62,5 @@ export const formatEtaMessage = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}` ? `Ready ${formatEtaClock(etaSeconds, now)}`
: `Ready in about ${formatDuration(etaSeconds)}` : `Ready in about ${formatDuration(etaSeconds)}`
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
+49 -4
View File
@@ -4,7 +4,11 @@ import { z } from 'zod'
import { db } from '#/db/index.ts' import { db } from '#/db/index.ts'
import * as ordering from '#/domain/ordering.ts' import * as ordering from '#/domain/ordering.ts'
import { getPaymentProvider } from '#/lib/payment.ts' import { getPaymentProvider } from '#/lib/payment.ts'
import { getSessionCustomerId, getSessionUser, requireAdmin } from '#/server/auth.ts' import {
getSessionCustomerId,
getSessionUser,
requireAdmin,
} from '#/server/auth.ts'
const customerSchema = z.discriminatedUnion('type', [ const customerSchema = z.discriminatedUnion('type', [
z.object({ z.object({
@@ -92,9 +96,7 @@ export const getGuestOrders = createServerFn({ method: 'GET' })
), ),
}), }),
) )
.handler(async ({ data }) => .handler(async ({ data }) => ordering.getGuestOrderSummaries(db, data.refs))
ordering.getGuestOrderSummaries(db, data.refs),
)
export const cancelOrderItem = createServerFn({ method: 'POST' }) export const cancelOrderItem = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() })) .validator(accessSchema.extend({ orderItemId: z.number() }))
@@ -122,6 +124,49 @@ export const markOrderItemPickedUp = createServerFn({ method: 'POST' })
// Deliberately not customer-facing and not access-checked here — in a real // Deliberately not customer-facing and not access-checked here — in a real
// deployment this must be restricted to an internal/ops caller, not exposed // deployment this must be restricted to an internal/ops caller, not exposed
// on a route a customer's session could reach. // on a route a customer's session could reach.
// 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 compressDurationMs = (seconds: number) =>
Math.min(Math.max(seconds * 50, 10_000), 30_000)
const SIMULATED_CANCEL_CHANCE = 0.015
const SIMULATED_DELAY_CHANCE = 0.08
const kitchenSimulationPolicy: ordering.KitchenSimulationPolicy = {
stepDurationMs: (step) =>
compressDurationMs(step.estimatedDurationSeconds) +
(step.delaySeconds > 0 ? compressDurationMs(step.delaySeconds) : 0),
pickOutcome: (step) => {
const roll = Math.random()
if (roll < SIMULATED_CANCEL_CHANCE) return { type: 'cancel' }
// One delay per Step at most, so a delayed Step always does finish.
if (
step.delaySeconds === 0 &&
roll < SIMULATED_CANCEL_CHANCE + SIMULATED_DELAY_CHANCE
) {
return {
type: 'delay',
delaySeconds: 120 + Math.floor(Math.random() * 180),
}
}
return { type: 'finish' }
},
}
export const advanceSimulatedKitchen = createServerFn({
method: 'POST',
}).handler(async () => {
if (!import.meta.env.DEV) {
throw new Error('The kitchen simulation is only available in development')
}
await ordering.runKitchenSimulationTick(
db,
getPaymentProvider(),
kitchenSimulationPolicy,
)
})
export const advanceOrderItemStep = createServerFn({ method: 'POST' }) export const advanceOrderItemStep = createServerFn({ method: 'POST' })
.validator(z.object({ orderItemId: z.number() })) .validator(z.object({ orderItemId: z.number() }))
.handler(async ({ data }) => .handler(async ({ data }) =>
+1
View File
@@ -11,6 +11,7 @@ $color-primary: var(--color-primary);
$color-primary-contrast: var(--color-primary-contrast); $color-primary-contrast: var(--color-primary-contrast);
$color-danger: var(--color-danger); $color-danger: var(--color-danger);
$color-success: var(--color-success); $color-success: var(--color-success);
$color-warning: var(--color-warning);
$space-1: 0.25rem; $space-1: 0.25rem;
$space-2: 0.5rem; $space-2: 0.5rem;
+2
View File
@@ -10,6 +10,7 @@
--color-primary-contrast: #ffffff; --color-primary-contrast: #ffffff;
--color-danger: #a33333; --color-danger: #a33333;
--color-success: #2f7a4f; --color-success: #2f7a4f;
--color-warning: #9a6200;
color-scheme: light; color-scheme: light;
} }
@@ -24,6 +25,7 @@
--color-primary-contrast: #ffffff; --color-primary-contrast: #ffffff;
--color-danger: #ef6b62; --color-danger: #ef6b62;
--color-success: #4fbb7c; --color-success: #4fbb7c;
--color-warning: #e0a83e;
color-scheme: dark; color-scheme: dark;
} }
} }
+150 -6
View File
@@ -132,7 +132,7 @@ describe('menu', () => {
]) ])
}) })
it('lists each menu item\'s customization Options', async () => { it("lists each menu item's customization Options", async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id) const menu = await ordering.getMenu(testDb.db, seed.brand.id)
const pizza = menu.find((item) => item.name === 'Pizza') const pizza = menu.find((item) => item.name === 'Pizza')
@@ -268,7 +268,7 @@ describe('placing an order', () => {
}) })
describe('customizing an item', () => { describe('customizing an item', () => {
it("charges the adjusted price for selected Options and snapshots them on the Order Item", async () => { it('charges the adjusted price for selected Options and snapshots them on the Order Item', async () => {
const extraCheese = seed.menuItemOptions.pizza.find( const extraCheese = seed.menuItemOptions.pizza.find(
(option) => option.name === 'Extra cheese', (option) => option.name === 'Extra cheese',
)! )!
@@ -390,6 +390,7 @@ describe('cancelling an Order Item', () => {
(item) => item.menuItemName === 'Burger', (item) => item.menuItemName === 'Burger',
)! )!
expect(cancelledPizza.status).toBe('cancelled') expect(cancelledPizza.status).toBe('cancelled')
expect(cancelledPizza.cancelledBy).toBe('customer')
expect(untouchedBurger.steps[0].status).toBe('in_progress') expect(untouchedBurger.steps[0].status).toBe('in_progress')
}) })
@@ -608,6 +609,96 @@ describe('advancing steps and pickup', () => {
}) })
}) })
describe('kitchen simulation', () => {
const policy = (
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
): ordering.KitchenSimulationPolicy => ({
stepDurationMs: (step) => (60 + step.delaySeconds) * 1000,
pickOutcome: () => outcome,
})
const inAMinute = () => new Date(Date.now() + 60_000)
it("finishes elapsed steps across every Order, unblocking an item queued behind someone else's", async () => {
const blockingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'queued',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
inAMinute(),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'done',
)
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
it('keeps a delayed step in progress and adds the delay to the ETA', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const before = (await getStatus(order)).items[0]
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'delay', delaySeconds: 180 }),
inAMinute(),
)
const after = (await getStatus(order)).items[0]
expect(after.steps[0].status).toBe('in_progress')
expect(after.delaySeconds).toBe(180)
expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170)
})
it('lets the kitchen cancel an item mid-step, refunding it and freeing its machine', async () => {
const cancelledOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'cancel' }),
inAMinute(),
)
const cancelledItem = (await getStatus(cancelledOrder)).items[0]
expect(cancelledItem.status).toBe('cancelled')
expect(cancelledItem.cancelledBy).toBe('kitchen')
expect(payment.refundsFor(cancelledOrder.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect((await getStatus(cancelledOrder)).status).toBe('complete')
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
})
describe('recipe snapshot', () => { describe('recipe snapshot', () => {
it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => { it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => {
const order = await placeGuestOrder([ const order = await placeGuestOrder([
@@ -755,10 +846,9 @@ describe('order history', () => {
{ menuItemId: seed.menuItems.burger.id }, { menuItemId: seed.menuItems.burger.id },
]) ])
const withCorrectToken = await ordering.getGuestOrderSummaries( const withCorrectToken = await ordering.getGuestOrderSummaries(testDb.db, [
testDb.db, { orderId: order.id, guestAccessToken: order.guestAccessToken! },
[{ orderId: order.id, guestAccessToken: order.guestAccessToken! }], ])
)
expect(withCorrectToken).toHaveLength(1) expect(withCorrectToken).toHaveLength(1)
expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken) expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken)
expect(withCorrectToken[0].items).toEqual([ expect(withCorrectToken[0].items).toEqual([
@@ -770,4 +860,58 @@ describe('order history', () => {
]) ])
expect(withWrongToken).toEqual([]) expect(withWrongToken).toEqual([])
}) })
it("reports an Order's progress from its items as they cook and get picked up", async () => {
const order = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
const progress = async () =>
(await ordering.getOrdersForCustomer(testDb.db, 'test-customer-1'))[0]
.progress
expect(await progress()).toBe('preparing')
const access = { customerId: 'test-customer-1' }
const itemId = (await ordering.getOrderStatus(testDb.db, order.id, access))
.items[0].id
await ordering.advanceOrderItemStep(testDb.db, itemId)
await ordering.advanceOrderItemStep(testDb.db, itemId)
expect(await progress()).toBe('ready')
await ordering.markOrderItemPickedUp(testDb.db, itemId, access)
expect(await progress()).toBe('picked_up')
})
it('reports an Order as not started while its only item waits for a busy machine', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }])
await placeAccountOrder(
[{ menuItemId: seed.menuItems.pizza.id }],
'test-customer-1',
)
const [order] = await ordering.getOrdersForCustomer(
testDb.db,
'test-customer-1',
)
expect(order.progress).toBe('not_started')
})
const paid = { status: 'paid', started: true } as const
const waiting = { status: 'paid', started: false } as const
const ready = { status: 'ready', started: true } as const
const pickedUp = { status: 'picked_up', started: true } as const
const cancelled = { status: 'cancelled', started: false } as const
it.each([
[[waiting, waiting], 'not_started'],
[[waiting, cancelled], 'not_started'],
[[waiting, paid], 'preparing'],
[[pickedUp, waiting], 'preparing'],
[[ready, waiting], 'partly_ready'],
[[ready, pickedUp], 'ready'],
[[cancelled, pickedUp], 'picked_up'],
[[cancelled, cancelled], 'cancelled'],
] as const)('derives progress from %j as %s', (items, expected) => {
expect(ordering.getOrderProgress([...items])).toBe(expected)
})
}) })