Collect ready items from pickup boxes instead of "I picked this up"

A ready Order Item now gets the lowest-numbered free Pickup Box (or waits
for one to free up), shown as "Pickup box N" on its status. The customer
opens the box from their order and closes it again; closing marks the
item picked up and hands the box to the next waiting item.

On the order card the box button comes first, full width on mobile, with
a padlock that lifts on open and snaps shut before the button collapses
away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:31:23 +02:00
co-authored by Claude Opus 5.5
parent ac2aa770fb
commit edd34a00a7
11 changed files with 1316 additions and 58 deletions
+4
View File
@@ -49,5 +49,9 @@ A capability label on a Machine (e.g. "oven", "grill", "assembly") describing wh
**Queue**:
The set of Preparation Steps awaiting a Machine carrying a given Machine Tag, ordered by turn. When such a Machine becomes idle, it takes the next eligible Step from that Tag's Queue.
**Pickup Box**:
A numbered locker at a Kitchen that a ready Order Item is placed in for the customer to collect. Each ready Order Item gets the lowest-numbered free box; if none is free, it waits for the next one to free up. The customer opens the box from their Order and closes it again after taking the item out — closing the box is what marks the Order Item picked up and frees the box.
_Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves)
**Operator**:
The human role attached to a Kitchen, responsible only for physically restocking ingredients and repairing machines. Never involved in cooking or fulfilling orders.
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE "pickup_boxes" (
"id" serial PRIMARY KEY NOT NULL,
"number" integer NOT NULL,
"is_open" boolean DEFAULT false NOT NULL,
CONSTRAINT "pickup_boxes_number_unique" UNIQUE("number")
);
--> statement-breakpoint
ALTER TABLE "order_items" ADD COLUMN "pickup_box_id" integer;--> statement-breakpoint
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_pickup_box_id_pickup_boxes_id_fk" FOREIGN KEY ("pickup_box_id") REFERENCES "public"."pickup_boxes"("id") ON DELETE no action ON UPDATE no action;
+942
View File
@@ -0,0 +1,942 @@
{
"id": "42d1f3c1-18f8-42a3-ac41-9e29a4b14add",
"prevId": "1cec3157-9481-4f6a-83b0-a79064d2bed5",
"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
},
"pickup_box_id": {
"name": "pickup_box_id",
"type": "integer",
"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"
},
"order_items_pickup_box_id_pickup_boxes_id_fk": {
"name": "order_items_pickup_box_id_pickup_boxes_id_fk",
"tableFrom": "order_items",
"tableTo": "pickup_boxes",
"columnsFrom": [
"pickup_box_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.pickup_boxes": {
"name": "pickup_boxes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pickup_boxes_number_unique": {
"name": "pickup_boxes_number_unique",
"nullsNotDistinct": false,
"columns": [
"number"
]
}
},
"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
@@ -57,6 +57,13 @@
"when": 1790804884144,
"tag": "0007_dazzling_norman_osborn",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1790845995013,
"tag": "0008_absurd_the_anarchist",
"breakpoints": true
}
]
}
@@ -299,6 +299,26 @@
}
}
.pickupButton {
overflow: hidden;
white-space: nowrap;
border-color: $color-text;
background: $color-text;
color: $color-background;
font-weight: 600;
&:hover:not(:disabled) {
border-color: $color-text;
opacity: 0.9;
}
@media (max-width: $breakpoint-tablet) {
flex-basis: 100%;
justify-content: center;
margin-bottom: $space-2;
}
}
.actionIcon {
width: 1rem;
height: 1rem;
+84 -21
View File
@@ -8,8 +8,9 @@ import { useBasket } from '#/lib/basket.tsx'
import {
advanceSimulatedKitchen,
cancelOrderItem,
closePickupBox,
getOrderStatus,
markOrderItemPickedUp,
openPickupBox,
} from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss'
@@ -31,6 +32,9 @@ const getItemBanner = (item: OrderItemStatusView) => {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
detail: item.pickupBox
? `Pickup box ${item.pickupBox.number}`
: 'Waiting for a free pickup box',
} as const
}
if (item.status === 'picked_up') {
@@ -82,11 +86,6 @@ const BackArrowIcon = () => (
const actionIconPaths = {
buyAgain: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'],
pickedUp: [
'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z',
'M3 6h18',
'M16 10a4 4 0 0 1-8 0',
],
help: [
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3',
@@ -112,6 +111,38 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
</svg>
)
const LOCK_DURATION_SECONDS = 0.25
// The shackle lifts while the box is open, and drops back first when the
// button leaves after closing, so the lock visibly snaps shut.
const LockIcon = ({ open }: { open: boolean }) => {
const reduceMotion = useReducedMotion()
const transition = reduceMotion
? { duration: 0 }
: { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const }
return (
<svg
className={styles.actionIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
<motion.path
d="M7 11V7a5 5 0 0 1 10 0v4"
initial={false}
animate={{ y: open ? -3 : 0 }}
exit={{ y: 0 }}
transition={transition}
/>
</svg>
)
}
const StepConnector = ({ filled }: { filled: boolean }) => {
const reduceMotion = useReducedMotion()
return (
@@ -194,6 +225,34 @@ export const OrderStatusView = ({
}
}
// Collapses to nothing on both axes, so the row closes the gap whether the
// button sits inline (desktop) or on its own full-width line (mobile); the
// negative margins cancel the flex gap it leaves behind.
const pickupButtonExit = reduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
width: 0,
height: 0,
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 0,
marginRight: -8,
marginBottom: -8,
transition: {
delay: LOCK_DURATION_SECONDS + 0.1,
duration: 0.3,
ease: 'easeInOut' as const,
},
}
const togglePickupBox = (item: OrderItemStatusView) => {
const toggle = item.pickupBox?.isOpen ? closePickupBox : openPickupBox
return toggle({ data: { orderItemId: item.id, guestAccessToken } })
}
const runKitchenSimulation = async () => {
setSimulating(true)
setError(null)
@@ -349,6 +408,25 @@ export const OrderStatusView = ({
<EtaMessage etaSeconds={item.etaSeconds} />
)}
<div className={styles.actions}>
<AnimatePresence initial={false}>
{item.pickupBox && (
<motion.button
key="pickupBox"
type="button"
className={styles.pickupButton}
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () => togglePickupBox(item))
}
exit={pickupButtonExit}
>
<LockIcon open={item.pickupBox.isOpen} />
{item.pickupBox.isOpen
? `Close box ${item.pickupBox.number}`
: `Open box ${item.pickupBox.number}`}
</motion.button>
)}
</AnimatePresence>
<button
type="button"
disabled={pendingItemId === item.id}
@@ -358,21 +436,6 @@ export const OrderStatusView = ({
Buy again
</button>
{item.status === 'ready' && (
<button
type="button"
disabled={pendingItemId === item.id}
onClick={() =>
void runAction(item.id, () =>
markOrderItemPickedUp({
data: { orderItemId: item.id, guestAccessToken },
}),
)
}
>
<ActionIcon name="pickedUp" />I picked this up
</button>
)}
<Link
to="/help"
search={{
+11
View File
@@ -1,4 +1,5 @@
import {
boolean,
integer,
pgTable,
serial,
@@ -108,6 +109,14 @@ export const menuItemAllergens = pgTable(
],
)
// A locker the customer collects a ready Order Item from, identified to them
// by the number on its door.
export const pickupBoxes = pgTable('pickup_boxes', {
id: serial().primaryKey(),
number: integer().notNull().unique(),
isOpen: boolean('is_open').notNull().default(false),
})
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
export type OrderStatus = (typeof orderStatusValues)[number]
@@ -151,6 +160,8 @@ export const orderItems = pgTable('order_items', {
status: text().$type<OrderItemStatus>().notNull().default('placed'),
// Only set once status is 'cancelled'.
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
// Set once the item is ready and a Pickup Box is free; kept after pickup.
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+6
View File
@@ -45,6 +45,11 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
.values({ name: 'Assembly Station 1', machineTagId: assemblyTag.id })
.returning()
const pickupBoxes = await db
.insert(schema.pickupBoxes)
.values([1, 2, 3, 4].map((number) => ({ number })))
.returning()
const [pizza] = await db
.insert(schema.menuItems)
.values({
@@ -177,6 +182,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
assembly: assemblyTag,
},
machines: { prep1, oven1, grill1, assembly1 },
pickupBoxes,
menuItems: { pizza, burger },
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
allergens,
+146 -19
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
@@ -32,6 +32,20 @@ export class OrderItemNotReadyError extends Error {
}
}
export class NoPickupBoxYetError extends Error {
constructor() {
super('No pickup box is free for this item yet')
this.name = 'NoPickupBoxYetError'
}
}
export class PickupBoxNotOpenError extends Error {
constructor() {
super('Open the pickup box before closing it')
this.name = 'PickupBoxNotOpenError'
}
}
export class NoStepInProgressError extends Error {
constructor() {
super('This item has no Preparation Step currently in progress')
@@ -617,6 +631,7 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
.update(schema.orderItems)
.set({ status: 'ready' })
.where(eq(schema.orderItems.id, orderItemId))
await assignFreePickupBoxes(tx)
}
// Freeing this machine may unblock another queued Step for the same Tag.
@@ -750,37 +765,126 @@ export const runKitchenSimulationTick = async (
}
}
export const markOrderItemPickedUp = async (
// Hands each free Pickup Box to a ready Order Item still waiting for one,
// oldest item first.
const assignFreePickupBoxes = async (db: DbOrTx) => {
const occupiedBoxIds = new Set(
(
await db
.select({ pickupBoxId: schema.orderItems.pickupBoxId })
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNotNull(schema.orderItems.pickupBoxId),
),
)
).map((row) => row.pickupBoxId),
)
const freeBoxes = (
await db
.select()
.from(schema.pickupBoxes)
.orderBy(asc(schema.pickupBoxes.number))
).filter((box) => !occupiedBoxIds.has(box.id))
if (freeBoxes.length === 0) return
const waitingItems = await db
.select()
.from(schema.orderItems)
.where(
and(
eq(schema.orderItems.status, 'ready'),
isNull(schema.orderItems.pickupBoxId),
),
)
.orderBy(asc(schema.orderItems.id))
.limit(freeBoxes.length)
for (const [index, item] of waitingItems.entries()) {
await db
.update(schema.orderItems)
.set({ pickupBoxId: freeBoxes[index].id })
.where(eq(schema.orderItems.id, item.id))
}
}
const getPickupBoxForReadyItem = async (
tx: Tx,
orderItemId: number,
access: OrderAccess,
) => {
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
const order = firstRow(
await tx
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
const box = firstRow(
await tx
.select()
.from(schema.pickupBoxes)
.where(eq(schema.pickupBoxes.id, orderItem.pickupBoxId)),
)
if (!box) throw new Error('Pickup box not found')
return { orderItem, box }
}
// Stand-in for unlocking the physical box door until hardware integration
// lands, like the kitchen simulation does for Machines.
export const openPickupBox = async (
db: Db,
orderItemId: number,
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
await tx
.update(schema.pickupBoxes)
.set({ isOpen: true })
.where(eq(schema.pickupBoxes.id, box.id))
})
}
// Closing the box after taking the item out is what counts as picking it up.
export const closePickupBox = async (
db: Db,
orderItemId: number,
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
const { orderItem, box } = await getPickupBoxForReadyItem(
tx,
orderItemId,
access,
)
if (!orderItem) throw new Error('Order item not found')
const order = firstRow(
await tx
.select()
.from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)),
)
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (!box.isOpen) throw new PickupBoxNotOpenError()
await tx
.update(schema.pickupBoxes)
.set({ isOpen: false })
.where(eq(schema.pickupBoxes.id, box.id))
await tx
.update(schema.orderItems)
.set({ status: 'picked_up' })
.where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
await assignFreePickupBoxes(tx)
})
}
@@ -804,6 +908,8 @@ export type OrderItemStatusView = {
// Menu Item recreates its Options under new ids, so the ids at order time
// can't be reused. Options since removed from the menu are left out.
buyAgainOptionIds: Array<number>
// Only set while the item is ready and waiting in a box.
pickupBox: { number: number; isOpen: boolean } | null
etaSeconds: number | null
}
@@ -843,6 +949,20 @@ export const getOrderStatus = async (
.from(schema.orderItemOptions)
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const pickupBoxIds = items
.filter((item) => item.status === 'ready')
.map((item) => item.pickupBoxId)
.filter((id) => id !== null)
const pickupBoxesById = new Map(
(pickupBoxIds.length === 0
? []
: await db
.select()
.from(schema.pickupBoxes)
.where(inArray(schema.pickupBoxes.id, pickupBoxIds))
).map((box) => [box.id, box]),
)
const currentOptionRows = await db
.select()
.from(schema.menuItemOptions)
@@ -1021,6 +1141,10 @@ export const getOrderStatus = async (
const options = optionRows.filter(
(option) => option.orderItemId === item.id,
)
const pickupBox =
item.status === 'ready' && item.pickupBoxId !== null
? pickupBoxesById.get(item.pickupBoxId)
: undefined
return {
id: item.id,
@@ -1045,6 +1169,9 @@ export const getOrderStatus = async (
options.some((option) => option.name === current.name),
)
.map((current) => current.id),
pickupBox: pickupBox
? { number: pickupBox.number, isOpen: pickupBox.isOpen }
: null,
etaSeconds,
}
})
+8 -6
View File
@@ -109,14 +109,16 @@ export const cancelOrderItem = createServerFn({ method: 'POST' })
),
)
export const markOrderItemPickedUp = createServerFn({ method: 'POST' })
export const openPickupBox = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
ordering.markOrderItemPickedUp(
db,
data.orderItemId,
await resolveAccess(data),
),
ordering.openPickupBox(db, data.orderItemId, await resolveAccess(data)),
)
export const closePickupBox = createServerFn({ method: 'POST' })
.validator(accessSchema.extend({ orderItemId: z.number() }))
.handler(async ({ data }) =>
ordering.closePickupBox(db, data.orderItemId, await resolveAccess(data)),
)
// Internal stand-in for a Machine finishing a Preparation Step, until
+79 -12
View File
@@ -44,6 +44,7 @@ afterEach(async () => {
await testDb.db.delete(schema.orderItemOptions)
await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders)
await testDb.db.update(schema.pickupBoxes).set({ isOpen: false })
})
const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
@@ -71,6 +72,23 @@ const accessFor = (order: { guestAccessToken: string | null }) => ({
const getStatus = (order: { id: number; guestAccessToken: string | null }) =>
ordering.getOrderStatus(testDb.db, order.id, accessFor(order))
const pickUp = async (orderItemId: number, access: ordering.OrderAccess) => {
await ordering.openPickupBox(testDb.db, orderItemId, access)
await ordering.closePickupBox(testDb.db, orderItemId, access)
}
// The burger's two Steps each start straight away while no other Order is
// cooking, so advancing twice leaves it ready.
const placeReadyBurger = async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const itemId = (await getStatus(order)).items[0].id
await ordering.advanceOrderItemStep(testDb.db, itemId)
await ordering.advanceOrderItemStep(testDb.db, itemId)
return { order, itemId }
}
describe('menu', () => {
it('lists menu items for a brand with their recipe steps', async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
@@ -554,11 +572,7 @@ describe('advancing steps and pickup', () => {
expect(status.items[0].steps[1].status).toBe('done')
expect(status.items[0].status).toBe('ready')
await ordering.markOrderItemPickedUp(
testDb.db,
burgerItemId,
accessFor(order),
)
await pickUp(burgerItemId, accessFor(order))
status = await getStatus(order)
expect(status.items[0].status).toBe('picked_up')
@@ -593,22 +607,75 @@ describe('advancing steps and pickup', () => {
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
})
it('refuses to mark an item picked up before it is ready', async () => {
it('refuses to open a pickup box before the item is ready', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const status = await getStatus(order)
await expect(
ordering.markOrderItemPickedUp(
testDb.db,
status.items[0].id,
accessFor(order),
),
ordering.openPickupBox(testDb.db, status.items[0].id, accessFor(order)),
).rejects.toThrow(ordering.OrderItemNotReadyError)
})
})
describe('pickup boxes', () => {
it('puts a ready item in the lowest-numbered free box', async () => {
const { order } = await placeReadyBurger()
const [item] = (await getStatus(order)).items
expect(item.pickupBox).toEqual({ number: 1, isOpen: false })
})
it('picks the item up once the customer opens and closes its box', async () => {
const { order, itemId } = await placeReadyBurger()
await ordering.openPickupBox(testDb.db, itemId, accessFor(order))
expect((await getStatus(order)).items[0].pickupBox).toEqual({
number: 1,
isOpen: true,
})
await ordering.closePickupBox(testDb.db, itemId, accessFor(order))
const status = await getStatus(order)
expect(status.items[0].status).toBe('picked_up')
expect(status.items[0].pickupBox).toBeNull()
expect(status.status).toBe('complete')
})
it("refuses to close a box that hasn't been opened", async () => {
const { order, itemId } = await placeReadyBurger()
await expect(
ordering.closePickupBox(testDb.db, itemId, accessFor(order)),
).rejects.toThrow(ordering.PickupBoxNotOpenError)
expect((await getStatus(order)).items[0].status).toBe('ready')
})
it('makes a ready item wait for a box while all are taken, then gives it the first one freed', async () => {
const filling = []
for (const _ of seed.pickupBoxes) filling.push(await placeReadyBurger())
const waiting = await placeReadyBurger()
expect((await getStatus(waiting.order)).items[0].pickupBox).toBeNull()
await expect(
ordering.openPickupBox(
testDb.db,
waiting.itemId,
accessFor(waiting.order),
),
).rejects.toThrow(ordering.NoPickupBoxYetError)
const boxTwo = filling[1]
await pickUp(boxTwo.itemId, accessFor(boxTwo.order))
expect((await getStatus(waiting.order)).items[0].pickupBox).toEqual({
number: 2,
isOpen: false,
})
})
})
describe('kitchen simulation', () => {
const policy = (
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
@@ -935,7 +1002,7 @@ describe('order history', () => {
await ordering.advanceOrderItemStep(testDb.db, itemId)
expect(await progress()).toBe('ready')
await ordering.markOrderItemPickedUp(testDb.db, itemId, access)
await pickUp(itemId, access)
expect(await progress()).toBe('picked_up')
})