diff --git a/CONTEXT.md b/CONTEXT.md
index bec8486..4095f4a 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -16,7 +16,7 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
**Order**:
-A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
+A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up, cancelled, or discarded. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
**Charge**:
The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment.
@@ -50,7 +50,7 @@ A capability label on a Machine (e.g. "oven", "grill", "assembly") describing wh
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.
+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. The customer has 15 minutes from when the item goes into the box (the pickup window); after that the item is discarded and the box freed, with no refund. A box that is open when the window runs out isn't emptied, since its customer is taking the item out.
_Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves)
**Operator**:
diff --git a/drizzle/0010_lush_black_bolt.sql b/drizzle/0010_lush_black_bolt.sql
new file mode 100644
index 0000000..f424211
--- /dev/null
+++ b/drizzle/0010_lush_black_bolt.sql
@@ -0,0 +1 @@
+ALTER TABLE "order_items" ADD COLUMN "placed_in_box_at" timestamp;
\ No newline at end of file
diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json
new file mode 100644
index 0000000..271fc2c
--- /dev/null
+++ b/drizzle/meta/0010_snapshot.json
@@ -0,0 +1,954 @@
+{
+ "id": "79d9d9d2-6e88-41ed-8d15-160c3549c881",
+ "prevId": "4826f90c-c779-4b2d-ab6e-491e72dbaee8",
+ "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
+ },
+ "placed_in_box_at": {
+ "name": "placed_in_box_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "picked_up_at": {
+ "name": "picked_up_at",
+ "type": "timestamp",
+ "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": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 11fdbe8..3654cbd 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1790888552762,
"tag": "0009_damp_ender_wiggin",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1790889640651,
+ "tag": "0010_lush_black_bolt",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss
index 39b0f65..45ab3fd 100644
--- a/src/components/orders/OrderStatusView.module.scss
+++ b/src/components/orders/OrderStatusView.module.scss
@@ -373,6 +373,7 @@
.pickupButton {
overflow: hidden;
white-space: nowrap;
+ transition: opacity 0.3s ease;
border-color: $color-text;
background: $color-text;
color: $color-background;
diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx
index e5c719a..0abba70 100644
--- a/src/components/orders/OrderStatusView.tsx
+++ b/src/components/orders/OrderStatusView.tsx
@@ -1,11 +1,12 @@
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { HoldButton } from '#/components/ui/HoldButton.tsx'
import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { useBasket } from '#/lib/basket.tsx'
+import { formatCountdown } from '#/lib/format.ts'
import {
advanceSimulatedKitchen,
cancelOrderItem,
@@ -23,19 +24,32 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const isSettled = (item: OrderItemStatusView) =>
item.status === 'ready' ||
item.status === 'picked_up' ||
- item.status === 'cancelled'
+ item.status === 'cancelled' ||
+ item.status === 'discarded'
// `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) => {
+// `now` is null until the page hydrates, so the server and first client
+// render agree and the countdown only appears once it can tick.
+const getItemBanner = (item: OrderItemStatusView, now: number | null) => {
if (item.status === 'ready') {
return {
animationKey: 'done',
variant: 'ready',
label: 'Ready for pickup',
- detail: item.pickupBox
- ? `Pickup box ${item.pickupBox.number}`
- : 'Waiting for a free pickup box',
+ detail: !item.pickupBox
+ ? 'Waiting for a free pickup box'
+ : item.pickupDeadline && now !== null
+ ? `Pickup box ${item.pickupBox.number} · ${formatCountdown(item.pickupDeadline.getTime() - now)} left`
+ : `Pickup box ${item.pickupBox.number}`,
+ } as const
+ }
+ if (item.status === 'discarded') {
+ return {
+ animationKey: 'discarded',
+ variant: 'cancelled',
+ label: 'Not picked up in time',
+ detail: `${item.pickupBox ? `Pickup box ${item.pickupBox.number}` : 'The pickup box'} was emptied after 15 minutes. This item can't be refunded.`,
} as const
}
if (item.status === 'picked_up') {
@@ -68,7 +82,7 @@ const getItemBanner = (item: OrderItemStatusView) => {
}
const bannerKey = (item: OrderItemStatusView) => {
- const banner = getItemBanner(item)
+ const banner = getItemBanner(item, null)
return banner ? `${item.id}:${banner.animationKey}` : null
}
@@ -116,20 +130,28 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
)
-const LOCK_DURATION_SECONDS = 0.25
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
// Same commands in both so the shackle morphs: unlocked, the left leg
// stretches up from the body and the right leg shrinks to a stub.
const LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4'
const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1'
+// Overshoots past the target, so the shackle springs open and snaps shut.
+const SHACKLE_EASE = [0.34, 1.8, 0.64, 1] as const
+
+const pickupButtonLabel = (
+ box: { number: number; isOpen: boolean },
+ closed: boolean,
+) => {
+ if (closed) return `Box ${box.number} closed`
+ return box.isOpen
+ ? `Hold to close box ${box.number}`
+ : `Hold to open box ${box.number}`
+}
const LockIcon = ({ open }: { open: boolean }) => {
const reduceMotion = useReducedMotion()
- const transition = reduceMotion
- ? { duration: 0 }
- : { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const }
return (
-
+
)
}
@@ -194,10 +236,22 @@ export const OrderStatusView = ({
const [initialBannerKeys] = useState(
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
)
- // A closed box's button stays on screen, disabled and locked, for a while
- // after pickup; re-render when the next one is due to animate out.
+ // Ticks every second while a pickup countdown runs; otherwise only wakes
+ // when a closed box's button (kept for a while after pickup) is due to go.
const [now, setNow] = useState(() => Date.now())
+ const [hydrated, setHydrated] = useState(false)
+ useEffect(() => setHydrated(true), [])
+ const hasPickupCountdown = status.items.some(
+ (item) => item.pickupDeadline !== null,
+ )
useEffect(() => {
+ if (!hasPickupCountdown) return
+ setNow(Date.now())
+ const interval = setInterval(() => setNow(Date.now()), 1000)
+ return () => clearInterval(interval)
+ }, [hasPickupCountdown])
+ useEffect(() => {
+ if (hasPickupCountdown) return
const hideAt = status.items
.flatMap((item) =>
item.pickedUpAt
@@ -211,7 +265,22 @@ export const OrderStatusView = ({
Math.min(...hideAt) - Date.now(),
)
return () => clearTimeout(timeout)
- }, [status.items, now])
+ }, [status.items, now, hasPickupCountdown])
+ // Once a countdown runs out, reload so the server can empty the box and the
+ // item shows as discarded. Only once per item: an open box isn't emptied.
+ const refreshedExpiredItemIds = useRef(new Set())
+ useEffect(() => {
+ const newlyExpired = status.items.filter(
+ (item) =>
+ item.pickupDeadline !== null &&
+ item.pickupDeadline.getTime() <= now &&
+ !refreshedExpiredItemIds.current.has(item.id),
+ )
+ if (newlyExpired.length === 0) return
+ for (const item of newlyExpired)
+ refreshedExpiredItemIds.current.add(item.id)
+ void router.invalidate()
+ }, [status.items, now, router])
const [expandedItemIds, setExpandedItemIds] = useState>(
new Set(),
)
@@ -343,7 +412,7 @@ export const OrderStatusView = ({
{error && {error}
}
{status.items.map((item) => {
- const banner = getItemBanner(item)
+ const banner = getItemBanner(item, hydrated ? now : null)
const boxClosed = item.status !== 'ready'
const pickupBox =
item.status === 'ready' ||
@@ -489,11 +558,17 @@ export const OrderStatusView = ({
exit={pickupButtonExit}
>
- {boxClosed
- ? `Box ${pickupBox.number} closed`
- : pickupBox.isOpen
- ? `Hold to close box ${pickupBox.number}`
- : `Hold to open box ${pickupBox.number}`}
+
+
+ {pickupButtonLabel(pickupBox, boxClosed)}
+
+
)}
diff --git a/src/components/orders/OrdersListView.module.scss b/src/components/orders/OrdersListView.module.scss
index 927a7a6..1b2fa1a 100644
--- a/src/components/orders/OrdersListView.module.scss
+++ b/src/components/orders/OrdersListView.module.scss
@@ -138,7 +138,8 @@
color: $color-success;
}
- &[data-progress='cancelled'] {
+ &[data-progress='cancelled'],
+ &[data-progress='discarded'] {
color: $color-danger;
}
}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index c843dc2..ed57058 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -141,6 +141,8 @@ export const orderItemStatusValues = [
'ready',
'picked_up',
'cancelled',
+ // Left uncollected in its Pickup Box past the pickup window; not refunded.
+ 'discarded',
] as const
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
@@ -162,6 +164,8 @@ export const orderItems = pgTable('order_items', {
cancelledBy: text('cancelled_by').$type(),
// Set once the item is ready and a Pickup Box is free; kept after pickup.
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
+ // When the item went into its box, which starts the pickup window.
+ placedInBoxAt: timestamp('placed_in_box_at'),
pickedUpAt: timestamp('picked_up_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts
index ab23757..8ddfd40 100644
--- a/src/domain/ordering.ts
+++ b/src/domain/ordering.ts
@@ -1,4 +1,4 @@
-import { and, asc, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'
+import { and, asc, desc, eq, inArray, isNotNull, isNull, lt } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from '#/db/schema.ts'
@@ -39,6 +39,13 @@ export class NoPickupBoxYetError extends Error {
}
}
+export class PickupWindowExpiredError extends Error {
+ constructor() {
+ super('This item was not picked up in time and has been discarded')
+ this.name = 'PickupWindowExpiredError'
+ }
+}
+
export class PickupBoxNotOpenError extends Error {
constructor() {
super('Open the pickup box before closing it')
@@ -491,7 +498,10 @@ const updateOrderCompleteIfEverySiblingIsDone = async (
.where(eq(schema.orderItems.orderId, orderId))
const orderComplete = siblingItems.every(
- (item) => item.status === 'picked_up' || item.status === 'cancelled',
+ (item) =>
+ item.status === 'picked_up' ||
+ item.status === 'cancelled' ||
+ item.status === 'discarded',
)
if (orderComplete) {
@@ -528,7 +538,11 @@ export const cancelOrderItem = async (
if (orderItem.status === 'cancelled') {
throw new OrderItemNotCancellableError('it has already been cancelled')
}
- if (orderItem.status === 'ready' || orderItem.status === 'picked_up') {
+ if (
+ orderItem.status === 'ready' ||
+ orderItem.status === 'picked_up' ||
+ orderItem.status === 'discarded'
+ ) {
throw new OrderItemNotCancellableError('it is already done cooking')
}
@@ -742,6 +756,8 @@ export const runKitchenSimulationTick = async (
policy: KitchenSimulationPolicy,
now = new Date(),
) => {
+ await discardUncollectedItems(db, now)
+
const inProgressSteps = await db
.select()
.from(schema.preparationStepInstances)
@@ -804,11 +820,55 @@ const assignFreePickupBoxes = async (db: DbOrTx) => {
for (const [index, item] of waitingItems.entries()) {
await db
.update(schema.orderItems)
- .set({ pickupBoxId: freeBoxes[index].id })
+ .set({ pickupBoxId: freeBoxes[index].id, placedInBoxAt: new Date() })
.where(eq(schema.orderItems.id, item.id))
}
}
+export const PICKUP_WINDOW_MS = 15 * 60 * 1000
+
+// Empties every box whose item has sat uncollected past the pickup window,
+// without a refund, and hands the freed boxes on. An open box is skipped: its
+// customer is taking the item out right now. Nothing runs this on a schedule
+// yet, so reads and box actions call it first to bring state up to date.
+export const discardUncollectedItems = async (db: DbOrTx, now = new Date()) => {
+ const expiredItems = await db
+ .select({
+ id: schema.orderItems.id,
+ orderId: schema.orderItems.orderId,
+ })
+ .from(schema.orderItems)
+ .innerJoin(
+ schema.pickupBoxes,
+ eq(schema.orderItems.pickupBoxId, schema.pickupBoxes.id),
+ )
+ .where(
+ and(
+ eq(schema.orderItems.status, 'ready'),
+ eq(schema.pickupBoxes.isOpen, false),
+ lt(
+ schema.orderItems.placedInBoxAt,
+ new Date(now.getTime() - PICKUP_WINDOW_MS),
+ ),
+ ),
+ )
+ if (expiredItems.length === 0) return
+
+ await db
+ .update(schema.orderItems)
+ .set({ status: 'discarded' })
+ .where(
+ inArray(
+ schema.orderItems.id,
+ expiredItems.map((item) => item.id),
+ ),
+ )
+ for (const orderId of new Set(expiredItems.map((item) => item.orderId))) {
+ await updateOrderCompleteIfEverySiblingIsDone(db, orderId)
+ }
+ await assignFreePickupBoxes(db)
+}
+
const getPickupBoxForReadyItem = async (
tx: Tx,
orderItemId: number,
@@ -831,6 +891,7 @@ const getPickupBoxForReadyItem = async (
if (!order) throw new Error('Order not found')
assertOrderAccess(order, access)
+ if (orderItem.status === 'discarded') throw new PickupWindowExpiredError()
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
@@ -852,6 +913,7 @@ export const openPickupBox = async (
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
+ await discardUncollectedItems(tx)
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
await tx
.update(schema.pickupBoxes)
@@ -867,6 +929,7 @@ export const closePickupBox = async (
access: OrderAccess,
) => {
await db.transaction(async (tx) => {
+ await discardUncollectedItems(tx)
const { orderItem, box } = await getPickupBoxForReadyItem(
tx,
orderItemId,
@@ -908,10 +971,13 @@ 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
- // The box a ready item waits in, still reported once it's picked up (with
- // isOpen false, since the box may since have gone to another item).
+ // The box a ready item waits in, still reported once it's picked up or
+ // discarded (with isOpen false, since the box may since have gone to
+ // another item).
pickupBox: { number: number; isOpen: boolean } | null
pickedUpAt: Date | null
+ // When the box will be emptied if the item is still there.
+ pickupDeadline: Date | null
etaSeconds: number | null
}
@@ -920,6 +986,8 @@ export const getOrderStatus = async (
orderId: number,
access: OrderAccess,
) => {
+ await discardUncollectedItems(db)
+
const order = firstRow(
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
)
@@ -952,7 +1020,6 @@ export const getOrderStatus = async (
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
const pickupBoxIds = items
- .filter((item) => item.status === 'ready' || item.status === 'picked_up')
.map((item) => item.pickupBoxId)
.filter((id) => id !== null)
const pickupBoxesById = new Map(
@@ -1134,7 +1201,8 @@ export const getOrderStatus = async (
if (
item.status === 'ready' ||
item.status === 'picked_up' ||
- item.status === 'cancelled'
+ item.status === 'cancelled' ||
+ item.status === 'discarded'
) {
etaSeconds = null
}
@@ -1144,7 +1212,6 @@ export const getOrderStatus = async (
(option) => option.orderItemId === item.id,
)
const pickupBox =
- (item.status === 'ready' || item.status === 'picked_up') &&
item.pickupBoxId !== null
? pickupBoxesById.get(item.pickupBoxId)
: undefined
@@ -1158,6 +1225,7 @@ export const getOrderStatus = async (
item.status !== 'cancelled' &&
item.status !== 'ready' &&
item.status !== 'picked_up' &&
+ item.status !== 'discarded' &&
currentInstance?.status === 'queued',
steps,
options: options.map(({ name, priceDeltaCents }) => ({
@@ -1179,6 +1247,10 @@ export const getOrderStatus = async (
}
: null,
pickedUpAt: item.pickedUpAt,
+ pickupDeadline:
+ item.status === 'ready' && item.placedInBoxAt
+ ? new Date(item.placedInBoxAt.getTime() + PICKUP_WINDOW_MS)
+ : null,
etaSeconds,
}
})
@@ -1198,9 +1270,15 @@ export const getOrderStatus = async (
export const getOrderProgress = (
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
) => {
- const activeItems = items.filter((item) => item.status !== 'cancelled')
+ const activeItems = items.filter(
+ (item) => item.status !== 'cancelled' && item.status !== 'discarded',
+ )
const activeStatuses = activeItems.map((item) => item.status)
- if (activeStatuses.length === 0) return 'cancelled'
+ if (activeStatuses.length === 0) {
+ return items.some((item) => item.status === 'discarded')
+ ? 'discarded'
+ : 'cancelled'
+ }
if (activeStatuses.every((status) => status === 'picked_up'))
return 'picked_up'
if (
@@ -1314,6 +1392,7 @@ export const getOrdersForCustomer = async (
db: Db,
customerId: string,
): Promise> => {
+ await discardUncollectedItems(db)
const orders = await db
.select()
.from(schema.orders)
@@ -1351,6 +1430,7 @@ export type AdminOrderSummary = CustomerOrderSummary & {
export const getAllOrders = async (
db: Db,
): Promise> => {
+ await discardUncollectedItems(db)
const orders = await db
.select()
.from(schema.orders)
@@ -1384,6 +1464,7 @@ export const getGuestOrderSummaries = async (
refs: Array,
): Promise> => {
if (refs.length === 0) return []
+ await discardUncollectedItems(db)
const tokenByOrderId = new Map(
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 97f63dc..9e59815 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -63,4 +63,11 @@ export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
+export const formatCountdown = (remainingMs: number) => {
+ const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ return `${minutes}:${String(seconds).padStart(2, '0')}`
+}
+
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
diff --git a/src/routes/help.tsx b/src/routes/help.tsx
index 0758d86..94c3cac 100644
--- a/src/routes/help.tsx
+++ b/src/routes/help.tsx
@@ -16,6 +16,7 @@ const orderStatusHelp: Record = {
ready: 'Your order is ready for pickup. If something’s off with it, let us know below.',
picked_up: 'This order has already been picked up. If something was wrong with it, let us know below.',
cancelled: 'This order was cancelled. If that wasn’t expected, let us know below.',
+ discarded: 'This item wasn’t picked up within 15 minutes, so its pickup box was emptied and it can’t be refunded. If something went wrong, let us know below.',
}
const faqs = [
diff --git a/tests/format.test.ts b/tests/format.test.ts
index e8a2f38..c637df2 100644
--- a/tests/format.test.ts
+++ b/tests/format.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
-import { formatDuration, formatEtaClock, formatEtaParts } from '#/lib/format.ts'
+import {
+ formatCountdown,
+ formatDuration,
+ formatEtaClock,
+ formatEtaParts,
+} from '#/lib/format.ts'
describe('formatDuration', () => {
it('rounds up to at least 1 minute', () => {
@@ -64,3 +69,15 @@ describe('formatEtaParts', () => {
})
})
})
+
+describe('formatCountdown', () => {
+ it('shows minutes and zero-padded seconds, rounding up', () => {
+ expect(formatCountdown(15 * 60 * 1000)).toBe('15:00')
+ expect(formatCountdown(65_500)).toBe('1:06')
+ expect(formatCountdown(9_000)).toBe('0:09')
+ })
+
+ it('stops at zero once the time is up', () => {
+ expect(formatCountdown(-5_000)).toBe('0:00')
+ })
+})
diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts
index bb861d0..9fed258 100644
--- a/tests/ordering.test.ts
+++ b/tests/ordering.test.ts
@@ -677,6 +677,64 @@ describe('pickup boxes', () => {
})
})
+describe('pickup window', () => {
+ const afterPickupWindow = () =>
+ new Date(Date.now() + ordering.PICKUP_WINDOW_MS + 1000)
+
+ it('gives a boxed item a deadline one pickup window from now', async () => {
+ const before = Date.now()
+ const { order } = await placeReadyBurger()
+
+ const deadline = (await getStatus(order)).items[0].pickupDeadline
+ expect(deadline).toBeInstanceOf(Date)
+ expect(deadline!.getTime()).toBeGreaterThanOrEqual(
+ before + ordering.PICKUP_WINDOW_MS,
+ )
+ expect(deadline!.getTime()).toBeLessThanOrEqual(
+ Date.now() + ordering.PICKUP_WINDOW_MS,
+ )
+ })
+
+ it('discards an item left past the window without a refund and frees its box', async () => {
+ const { order } = await placeReadyBurger()
+
+ await ordering.discardUncollectedItems(testDb.db, afterPickupWindow())
+
+ const status = await getStatus(order)
+ expect(status.items[0].status).toBe('discarded')
+ expect(status.items[0].pickupBox).toEqual({ number: 1, isOpen: false })
+ expect(status.items[0].pickupDeadline).toBeNull()
+ expect(status.status).toBe('complete')
+ expect(payment.refundsFor(order.paymentChargeId!)).toBe(0)
+
+ const next = await placeReadyBurger()
+ expect((await getStatus(next.order)).items[0].pickupBox).toEqual({
+ number: 1,
+ isOpen: false,
+ })
+ })
+
+ it('leaves an item alone while its box is open, so closing still picks it up', async () => {
+ const { order, itemId } = await placeReadyBurger()
+ await ordering.openPickupBox(testDb.db, itemId, accessFor(order))
+
+ await ordering.discardUncollectedItems(testDb.db, afterPickupWindow())
+ expect((await getStatus(order)).items[0].status).toBe('ready')
+
+ await ordering.closePickupBox(testDb.db, itemId, accessFor(order))
+ expect((await getStatus(order)).items[0].status).toBe('picked_up')
+ })
+
+ it('refuses to open the box of a discarded item', async () => {
+ const { order, itemId } = await placeReadyBurger()
+ await ordering.discardUncollectedItems(testDb.db, afterPickupWindow())
+
+ await expect(
+ ordering.openPickupBox(testDb.db, itemId, accessFor(order)),
+ ).rejects.toThrow(ordering.PickupWindowExpiredError)
+ })
+})
+
describe('kitchen simulation', () => {
const policy = (
outcome: ReturnType,
@@ -1026,6 +1084,7 @@ describe('order history', () => {
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
+ const discarded = { status: 'discarded', started: true } as const
it.each([
[[waiting, waiting], 'not_started'],
@@ -1036,6 +1095,8 @@ describe('order history', () => {
[[ready, pickedUp], 'ready'],
[[cancelled, pickedUp], 'picked_up'],
[[cancelled, cancelled], 'cancelled'],
+ [[discarded, pickedUp], 'picked_up'],
+ [[discarded, cancelled], 'discarded'],
] as const)('derives progress from %j as %s', (items, expected) => {
expect(ordering.getOrderProgress([...items])).toBe(expected)
})